diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e29853e5..96926eac4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,10 @@ val lastCommitHash = providers.exec { commandLine("git", "rev-parse", "--short", "HEAD") }.standardOutput.asText.map { it.trim() } +val lastCommitTimestamp = providers.exec { + commandLine("git", "log", "-1", "--format=%ct") +}.standardOutput.asText.map { it.trim() } + java { toolchain { languageVersion = JavaLanguageVersion.of(21) @@ -69,6 +73,7 @@ configure { testInstrumentationRunnerArguments["disableAnalytics"] = "true" buildConfigField("String", "EXODUS_API_KEY", "\"bbe6ebae4ad45a9cbacb17d69739799b8df2c7ae\"") + buildConfigField("long", "BUILD_TIMESTAMP", "${lastCommitTimestamp.get()}L") missingDimensionStrategy("device", "vanilla") } @@ -191,6 +196,7 @@ dependencies { // AndroidX implementation(libs.androidx.core.ktx) implementation(libs.androidx.browser) + implementation(libs.androidx.biometric) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.navigation3) implementation(libs.androidx.preference.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee020d093..0bf3e078d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -188,6 +188,11 @@ android:name=".data.receiver.DownloadCancelReceiver" android:exported="false" /> + + + = flow { var bytesCopied: Long = 0 val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = read(buffer) var lastTotalBytesRead: Long = 0 var speed: Long = 0 @@ -23,14 +22,20 @@ fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow lastTotalBytesRead = totalBytesRead } - while (bytes >= 0) { - out.write(buffer, 0, bytes) - out.flush() + // Cancel the timer even when the collector aborts mid-stream (e.g. the download is + // stopped), otherwise the timer thread would leak. + try { + var bytes = read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + out.flush() - bytesCopied += bytes - // Emit stream progress in percentage - emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed)) - bytes = read(buffer) + bytesCopied += bytes + // Emit stream progress in percentage + emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed)) + bytes = read(buffer) + } + } finally { + timer.cancel() } - timer.cancel() }.flowOn(Dispatchers.IO) diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt index ffa594b24..b98e6b6af 100644 --- a/app/src/main/java/com/aurora/store/ComposeActivity.kt +++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt @@ -8,33 +8,51 @@ package com.aurora.store import android.content.Intent import android.os.Bundle -import androidx.activity.ComponentActivity +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.core.content.IntentCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.extensions.getPackageName +import com.aurora.store.R import com.aurora.store.compose.composition.LocalNetworkStatus import com.aurora.store.compose.composition.LocalUI import com.aurora.store.compose.composition.UI import com.aurora.store.compose.navigation.NavDisplay import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.theme.AuroraTheme +import com.aurora.store.compose.ui.lock.AppLockScreen +import com.aurora.store.data.AppLockManager import com.aurora.store.data.model.NetworkStatus import com.aurora.store.data.providers.NetworkProvider import com.aurora.store.data.receiver.MigrationReceiver +import com.aurora.store.util.AppLockAuthenticator import com.aurora.store.util.PackageUtil import com.aurora.store.util.Preferences import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint -class ComposeActivity : ComponentActivity() { +class ComposeActivity : FragmentActivity() { @Inject lateinit var networkProvider: NetworkProvider + @Inject lateinit var appLockManager: AppLockManager + override fun onCreate(savedInstanceState: Bundle?) { MigrationReceiver.runMigrationsIfRequired(this) enableEdgeToEdge() @@ -53,17 +71,86 @@ class ComposeActivity : ComponentActivity() { val networkStatus by networkProvider.status.collectAsStateWithLifecycle( initialValue = NetworkStatus.AVAILABLE ) - CompositionLocalProvider( - LocalUI provides localUI, - LocalNetworkStatus provides networkStatus - ) { - AuroraTheme { - NavDisplay(startDestination = startDestination) + AuroraTheme { + var lockState by remember { + mutableStateOf( + if (appLockManager.shouldLock(this@ComposeActivity)) { + LockState.AUTHENTICATING + } else { + LockState.UNLOCKED + } + ) + } + + // Keep FLAG_SECURE on until unlocked; auto-prompt while authenticating + LaunchedEffect(lockState) { + when (lockState) { + LockState.AUTHENTICATING -> { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + promptUnlock( + onSuccess = { lockState = LockState.UNLOCKED }, + onError = { lockState = LockState.LOCKED } + ) + } + + LockState.LOCKED -> window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + LockState.UNLOCKED -> + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + // Re-lock when returning from the background past the grace timeout + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> appLockManager.onBackgrounded() + Lifecycle.Event.ON_START -> + if (lockState == LockState.UNLOCKED && + appLockManager.shouldLock(this@ComposeActivity) + ) { + lockState = LockState.AUTHENTICATING + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + when (lockState) { + LockState.UNLOCKED -> CompositionLocalProvider( + LocalUI provides localUI, + LocalNetworkStatus provides networkStatus + ) { + NavDisplay(startDestination = startDestination) + } + + // Plain surface behind the prompt so dismissing it doesn't flash the lock card + LockState.AUTHENTICATING -> Surface(modifier = Modifier.fillMaxSize()) {} + + LockState.LOCKED -> AppLockScreen( + onUnlock = { lockState = LockState.AUTHENTICATING } + ) } } } } + private fun promptUnlock(onSuccess: () -> Unit, onError: () -> Unit) { + AppLockAuthenticator.authenticate( + activity = this, + title = getString(R.string.app_lock_prompt_title), + subtitle = getString(R.string.app_lock_prompt_subtitle), + onSuccess = { + appLockManager.markUnlocked() + onSuccess() + }, + onError = { onError() } + ) + } + private fun resolveStartDestination(): Screen { // Parcel-based navigation (e.g. from NotificationUtil) IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java) @@ -91,4 +178,6 @@ class ComposeActivity : ComponentActivity() { !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding else -> Screen.Splash() } + + private enum class LockState { AUTHENTICATING, LOCKED, UNLOCKED } } diff --git a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt index cedf13fb0..203e08af2 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt @@ -61,7 +61,7 @@ fun DownloadListItem(modifier: Modifier = Modifier, download: Download, onClick: ) }, trailing = when (download.status) { - DownloadStatus.COMPLETED -> { + DownloadStatus.COMPLETED, DownloadStatus.INSTALLED -> { { Icon( painter = painterResource(R.drawable.ic_check), diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt index 8f8bc2b4f..4c81f2368 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt @@ -44,6 +44,8 @@ sealed class Destination { data object NetworkPreference : Destination() data object Dispenser : Destination() data object UIPreference : Destination() + data object NotificationPreference : Destination() data object UpdatesPreference : Destination() data object SourceFilters : Destination() + data object SecurityPreference : Destination() } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index 47ee6896f..857461033 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -48,11 +48,13 @@ import com.aurora.store.compose.ui.favourite.FavouriteScreen import com.aurora.store.compose.ui.installed.InstalledScreen import com.aurora.store.compose.ui.main.MainScreen import com.aurora.store.compose.ui.onboarding.OnboardingScreen +import com.aurora.store.compose.ui.preferences.NotificationPreferenceScreen import com.aurora.store.compose.ui.preferences.SettingsScreen import com.aurora.store.compose.ui.preferences.UIPreferenceScreen import com.aurora.store.compose.ui.preferences.installation.InstallationPreferenceScreen import com.aurora.store.compose.ui.preferences.installation.InstallerScreen import com.aurora.store.compose.ui.preferences.network.NetworkPreferenceScreen +import com.aurora.store.compose.ui.preferences.security.SecurityPreferenceScreen import com.aurora.store.compose.ui.preferences.updates.SourceFiltersScreen import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen import com.aurora.store.compose.ui.search.SearchScreen @@ -160,8 +162,10 @@ fun NavDisplay(startDestination: NavKey) { Destination.NetworkPreference -> backstack.add(Screen.NetworkPreference) Destination.Dispenser -> backstack.add(Screen.Dispenser) Destination.UIPreference -> backstack.add(Screen.UIPreference) + Destination.NotificationPreference -> backstack.add(Screen.NotificationPreference) Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference) Destination.SourceFilters -> backstack.add(Screen.SourceFilters) + Destination.SecurityPreference -> backstack.add(Screen.SecurityPreference) } } @@ -282,8 +286,10 @@ fun NavDisplay(startDestination: NavKey) { entry { SettingsScreen(onNavigateTo = ::navigate) } entry { NetworkPreferenceScreen(onNavigateTo = ::navigate) } entry { UIPreferenceScreen() } + entry { NotificationPreferenceScreen() } entry { UpdatesPreferenceScreen(onNavigateTo = ::navigate) } entry { SourceFiltersScreen() } + entry { SecurityPreferenceScreen() } } ) } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt index 1ce32189a..8cebacce6 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt @@ -89,12 +89,18 @@ sealed class Screen : NavKey, Parcelable { @Serializable data object UIPreference : Screen() + @Serializable + data object NotificationPreference : Screen() + @Serializable data object UpdatesPreference : Screen() @Serializable data object SourceFilters : Screen() + @Serializable + data object SecurityPreference : Screen() + @Serializable data class Splash(val packageName: String? = null) : Screen() diff --git a/app/src/main/java/com/aurora/store/compose/ui/lock/AppLockScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/lock/AppLockScreen.kt new file mode 100644 index 000000000..41ed48802 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/lock/AppLockScreen.kt @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.lock + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +/** + * Full-screen lock placeholder shown in place of the app content while the app is locked. + * The actual authentication prompt is driven by the hosting activity; [onUnlock] re-triggers + * it, e.g. after the user dismissed the prompt. + */ +@Composable +fun AppLockScreen(onUnlock: () -> Unit) { + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(R.drawable.ic_lock), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .padding(bottom = 16.dp) + ) + Text( + text = stringResource(R.string.app_lock_locked_message), + modifier = Modifier.padding(bottom = 24.dp), + textAlign = TextAlign.Center + ) + Button(onClick = onUnlock) { + Text(stringResource(R.string.app_lock_unlock)) + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun AppLockScreenPreview() { + AppLockScreen(onUnlock = {}) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/NotificationPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/NotificationPreferenceScreen.kt new file mode 100644 index 000000000..f4889cffc --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/NotificationPreferenceScreen.kt @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.core.net.toUri +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.aurora.Constants +import com.aurora.extensions.areNotificationsEnabled +import com.aurora.extensions.isOAndAbove +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_NOTIFICATION_PROGRESS +import com.aurora.store.util.save + +@Composable +fun NotificationPreferenceScreen() { + ScreenContent() +} + +@Composable +private fun ScreenContent() { + val context = LocalContext.current + + var notificationsEnabled by remember { mutableStateOf(context.areNotificationsEnabled()) } + var showProgress by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_NOTIFICATION_PROGRESS, true)) + } + + // Re-read the system notification state when returning from settings. + LifecycleResumeEffect(Unit) { + notificationsEnabled = context.areNotificationsEnabled() + onPauseOrDispose {} + } + + // The channels users most likely want to fine-tune (sound, importance, on/off), paired + // with their display-name strings, deep-linked into the system per-channel settings. + val channels = listOf( + Constants.NOTIFICATION_CHANNEL_DOWNLOADS to R.string.notification_channel_downloads, + Constants.NOTIFICATION_CHANNEL_INSTALL to R.string.notification_channel_install, + Constants.NOTIFICATION_CHANNEL_UPDATES to R.string.notification_channel_updates, + Constants.NOTIFICATION_CHANNEL_ALERTS to R.string.notification_channel_alerts, + Constants.NOTIFICATION_CHANNEL_EXPORT to R.string.notification_channel_export + ) + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.title_notifications) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + if (!notificationsEnabled) { + item { + ListItem( + modifier = Modifier.clickable { openAppNotificationSettings(context) }, + headlineContent = { + Text(stringResource(R.string.pref_notification_disabled)) + }, + supportingContent = { + Text(stringResource(R.string.pref_notification_disabled_desc)) + } + ) + } + item { HorizontalDivider() } + } + + item { + ListItem( + modifier = Modifier.clickable { + showProgress = !showProgress + context.save(PREFERENCE_NOTIFICATION_PROGRESS, showProgress) + }, + headlineContent = { + Text(stringResource(R.string.pref_notification_progress)) + }, + supportingContent = { + Text(stringResource(R.string.pref_notification_progress_desc)) + }, + trailingContent = { + Switch( + checked = showProgress, + onCheckedChange = { checked -> + showProgress = checked + context.save(PREFERENCE_NOTIFICATION_PROGRESS, checked) + } + ) + } + ) + } + + // Per-channel system settings are only meaningful on Android O+ where channels + // exist; on older versions the app-level toggle above is the only control. + if (isOAndAbove) { + item { HorizontalDivider() } + item { + ListItem( + headlineContent = { + Text(stringResource(R.string.pref_notification_categories)) + }, + supportingContent = { + Text(stringResource(R.string.pref_notification_categories_desc)) + } + ) + } + channels.forEach { (channelId, nameRes) -> + item { + ListItem( + modifier = Modifier.clickable { + openChannelSettings(context, channelId) + }, + headlineContent = { Text(stringResource(nameRes)) } + ) + } + } + } + } + } +} + +private fun openAppNotificationSettings(context: android.content.Context) { + val intent = if (isOAndAbove) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData("package:${context.packageName}".toUri()) + } + context.startActivity(intent) +} + +private fun openChannelSettings(context: android.content.Context, channelId: String) { + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + context.startActivity(intent) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun NotificationPreferenceScreenPreview() { + ScreenContent() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt index a623e2a77..45bee12c4 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt @@ -88,6 +88,20 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) { headlineContent = { Text(stringResource(R.string.pref_ui_title)) } ) } + item { + ListItem( + modifier = Modifier.clickable { + onNavigateTo(Destination.NotificationPreference) + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_notification_settings), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.title_notifications)) } + ) + } item { ListItem( modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) }, @@ -112,6 +126,18 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) { headlineContent = { Text(stringResource(R.string.title_updates)) } ) } + item { + ListItem( + modifier = Modifier.clickable { onNavigateTo(Destination.SecurityPreference) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_lock), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.title_security)) } + ) + } } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt new file mode 100644 index 000000000..134fff43a --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences.security + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.extensions.toast +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.util.AppLockAuthenticator +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED +import com.aurora.store.util.save + +@Composable +fun SecurityPreferenceScreen() { + ScreenContent() +} + +@Composable +private fun ScreenContent() { + val context = LocalContext.current + var appLockEnabled by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false)) + } + + fun setAppLock(enabled: Boolean) { + // Refuse to enable the lock when the device has no biometric or screen-lock to fall back on + if (enabled && !AppLockAuthenticator.canAuthenticate(context)) { + context.toast(R.string.app_lock_no_credential) + return + } + appLockEnabled = enabled + context.save(PREFERENCE_APP_LOCK_ENABLED, enabled) + } + + Scaffold( + topBar = { TopAppBar(title = stringResource(R.string.title_security)) } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + item { + ListItem( + modifier = Modifier.clickable { setAppLock(!appLockEnabled) }, + headlineContent = { Text(stringResource(R.string.app_lock_title)) }, + supportingContent = { Text(stringResource(R.string.app_lock_summary)) }, + trailingContent = { + Switch( + checked = appLockEnabled, + onCheckedChange = { setAppLock(it) } + ) + } + ) + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun SecurityPreferenceScreenPreview() { + ScreenContent() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/UpdatesPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/UpdatesPreferenceScreen.kt index 9ff5b97cb..e99c3bceb 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/UpdatesPreferenceScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/UpdatesPreferenceScreen.kt @@ -55,6 +55,7 @@ import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog import com.aurora.store.data.model.UpdateMode +import com.aurora.store.util.PackageUtil import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE @@ -62,6 +63,7 @@ import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_METERE import com.aurora.store.util.Preferences.PREFERENCE_FILTER_AURORA_ONLY import com.aurora.store.util.Preferences.PREFERENCE_FILTER_FDROID import com.aurora.store.util.Preferences.PREFERENCE_FILTER_INSTALLERS +import com.aurora.store.util.Preferences.PREFERENCE_SELF_UPDATE_ENABLED import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_AUTO import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_CHECK_INTERVAL import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED @@ -79,6 +81,7 @@ fun UpdatesPreferenceScreen( onScheduleAutomatedCheck = { viewModel.updateHelper.scheduleAutomatedCheck() }, onUpdateAutomatedCheck = { viewModel.updateHelper.updateAutomatedCheck() }, onCheckUpdatesNow = { viewModel.updateHelper.checkUpdatesNow() }, + onDeleteSelfUpdate = { viewModel.updateHelper.deleteSelfUpdate() }, onNavigateTo = onNavigateTo ) } @@ -89,6 +92,7 @@ private fun ScreenContent( onScheduleAutomatedCheck: () -> Unit = {}, onUpdateAutomatedCheck: () -> Unit = {}, onCheckUpdatesNow: () -> Unit = {}, + onDeleteSelfUpdate: () -> Unit = {}, onNavigateTo: (Destination) -> Unit = {} ) { val context = LocalContext.current @@ -109,6 +113,10 @@ private fun ScreenContent( var updatesExtended by remember { mutableStateOf(Preferences.getBoolean(context, PREFERENCE_UPDATES_EXTENDED)) } + val selfUpdateSupported = remember { PackageUtil.isSelfUpdateSupported(context) } + var selfUpdateEnabled by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_SELF_UPDATE_ENABLED, true)) + } // Re-read source-filter prefs after returning from SourceFiltersScreen. val lifecycleOwner = LocalLifecycleOwner.current @@ -335,6 +343,30 @@ private fun ScreenContent( } ) } + if (selfUpdateSupported) { + item { + fun onSelfUpdateChanged(enabled: Boolean) { + selfUpdateEnabled = enabled + context.save(PREFERENCE_SELF_UPDATE_ENABLED, enabled) + if (enabled) onCheckUpdatesNow() else onDeleteSelfUpdate() + } + ListItem( + modifier = Modifier.clickable { + onSelfUpdateChanged(!selfUpdateEnabled) + }, + headlineContent = { Text(stringResource(R.string.pref_self_update)) }, + supportingContent = { + Text(stringResource(R.string.pref_self_update_desc)) + }, + trailingContent = { + Switch( + checked = selfUpdateEnabled, + onCheckedChange = ::onSelfUpdateChanged + ) + } + ) + } + } } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt index 50e4f67d8..e562b1ef2 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt @@ -59,19 +59,21 @@ fun UpdatesScreen( } val groupedUpdates = remember(updateMap) { + val self = mutableListOf>() val main = mutableListOf>() val approval = mutableListOf>() val incompatible = mutableListOf>() updateMap?.entries?.forEach { entry -> when { + entry.key.isSelfUpdate(context) -> self += entry entry.key.isIncompatible -> incompatible += entry entry.key.requiresOwnershipTransfer(context) -> approval += entry else -> main += entry } } - Triple(main.toList(), approval.toList(), incompatible.toList()) + listOf(self.toList(), main.toList(), approval.toList(), incompatible.toList()) } - val (mainEntries, approvalEntries, incompatibleEntries) = groupedUpdates + val (selfEntries, mainEntries, approvalEntries, incompatibleEntries) = groupedUpdates val mainAnyActive = mainEntries.any { it.value.isActive() } val approvalAnyActive = approvalEntries.any { it.value.isActive() } @@ -110,6 +112,29 @@ fun UpdatesScreen( dimensionResource(R.dimen.spacing_medium) ) ) { + if (selfEntries.isNotEmpty()) { + item(key = "header_self") { + SectionHeader( + title = stringResource(R.string.updates_self_header), + subtitle = stringResource(R.string.updates_self_desc) + ) + } + items( + items = selfEntries, + key = { "self-${it.key.packageName}" } + ) { (update, download) -> + AppUpdateItem( + update = update, + download = download, + // Served from the Aurora OSS feed, not Play — there is + // no app details page to open. + onClick = {}, + onUpdate = { onRequestUpdate(update) }, + onCancel = { onCancelUpdate(update.packageName) } + ) + } + } + if (mainEntries.isNotEmpty()) { item(key = "header_main") { val title = "${mainEntries.size} " + stringResource( diff --git a/app/src/main/java/com/aurora/store/data/AppLockManager.kt b/app/src/main/java/com/aurora/store/data/AppLockManager.kt new file mode 100644 index 000000000..d8253f908 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/AppLockManager.kt @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data + +import android.content.Context +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED +import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_TIMEOUT +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tracks the app-lock state at process scope. The unlocked flag lives only in memory, so a + * fresh process (cold start or process death) always starts locked. Re-locking after the + * app is backgrounded is governed by a short grace [timeout] to avoid re-prompting during + * transient stops such as the system install dialog or the biometric sheet itself. + */ +@Singleton +class AppLockManager @Inject constructor() { + + companion object { + // Grace period before a backgrounded app re-locks, in seconds + private const val DEFAULT_TIMEOUT_SECONDS = 30 + } + + private var unlocked = false + private var backgroundedAt = 0L + + /** + * Whether the app should currently present the lock screen, i.e. the feature is enabled + * and the session is not (still) unlocked within the grace [timeout]. + */ + fun shouldLock(context: Context): Boolean { + if (!Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false)) return false + if (!unlocked) return true + + if (backgroundedAt == 0L) return false + val elapsed = System.currentTimeMillis() - backgroundedAt + return elapsed >= timeoutMillis(context) + } + + /** Marks the current session as authenticated. */ + fun markUnlocked() { + unlocked = true + backgroundedAt = 0L + } + + /** Records when the app went to the background so the grace timeout can be measured. */ + fun onBackgrounded() { + if (unlocked) backgroundedAt = System.currentTimeMillis() + } + + private fun timeoutMillis(context: Context): Long { + val seconds = Preferences.getInteger( + context, + PREFERENCE_APP_LOCK_TIMEOUT, + DEFAULT_TIMEOUT_SECONDS + ) + return seconds * 1000L + } +} diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index 1fe336c11..af3aaffeb 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -2,14 +2,20 @@ package com.aurora.store.data.helper import android.content.Context import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager +import androidx.work.WorkRequest import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App import com.aurora.store.AuroraApp +import com.aurora.store.data.event.InstallerEvent +import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.DownloadDao @@ -18,6 +24,7 @@ import com.aurora.store.data.room.update.Update import com.aurora.store.data.work.DownloadWorker import com.aurora.store.util.PathUtil import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first @@ -32,7 +39,8 @@ import kotlinx.coroutines.launch */ class DownloadHelper @Inject constructor( @ApplicationContext private val context: Context, - private val downloadDao: DownloadDao + private val downloadDao: DownloadDao, + private val appInstaller: AppInstaller ) { companion object { @@ -68,13 +76,50 @@ class DownloadHelper @Inject constructor( cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList()) }.invokeOnCompletion { observeDownloads() + observeInstalls() } } + /** + * Advances a download row through the installer phase so its history reflects whether the + * app actually installed, not just that the bytes finished downloading: + * - [InstallerEvent.Installing] moves a [DownloadStatus.COMPLETED] row to + * [DownloadStatus.INSTALLING]; + * - [InstallerEvent.Installed] marks it [DownloadStatus.INSTALLED] (kept so the user can + * still export the APK); + * - [InstallerEvent.Failed] reverts an in-progress install back to + * [DownloadStatus.COMPLETED] so the downloaded files can be re-installed without + * re-downloading. + */ + private fun observeInstalls() { + AuroraApp.events.installerEvent.onEach { event -> + val existing = getDownload(event.packageName) ?: return@onEach + when (event) { + is InstallerEvent.Installing -> if (existing.status == DownloadStatus.COMPLETED) { + downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLING) + } + + is InstallerEvent.Installed -> if (existing.status != DownloadStatus.INSTALLED) { + downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLED) + } + + is InstallerEvent.Failed -> if (existing.status == DownloadStatus.INSTALLING) { + downloadDao.updateStatus(event.packageName, DownloadStatus.COMPLETED) + } + + else -> {} + } + }.launchIn(AuroraApp.scope) + } + private fun observeDownloads() { downloadDao.downloads().onEach { list -> try { - if (list.none { it.status == DownloadStatus.DOWNLOADING }) { + // Serialize downloads: only start the next queued item once nothing else is + // actively purchasing/downloading/verifying. Previously this only checked for + // DOWNLOADING, so a worker in PURCHASING/VERIFYING didn't count and a second + // download could start concurrently and clobber the shared notification. + if (list.none { it.status in DownloadStatus.processing }) { list.find { it.status == DownloadStatus.QUEUED } ?.let { queuedDownload -> Log.i(TAG, "Enqueued download worker for ${queuedDownload.packageName}") @@ -92,7 +137,7 @@ class DownloadHelper @Inject constructor( * @param app [App] to download */ suspend fun enqueueApp(app: App) { - downloadDao.insert(Download.fromApp(app)) + enqueue(Download.fromApp(app)) } /** @@ -100,7 +145,7 @@ class DownloadHelper @Inject constructor( * @param update [Update] to download */ suspend fun enqueueUpdate(update: Update) { - downloadDao.insert(Download.fromUpdate(update)) + enqueue(Download.fromUpdate(update)) } /** @@ -108,7 +153,57 @@ class DownloadHelper @Inject constructor( * @param externalApk [ExternalApk] to download */ suspend fun enqueueStandalone(externalApk: ExternalApk) { - downloadDao.insert(Download.fromExternalApk(externalApk)) + enqueue(Download.fromExternalApk(externalApk)) + } + + /** + * Inserts a new download row, but only when a (re)download is actually needed. For an + * existing record of the same version this: + * - **installs without re-downloading** if the files are already downloaded & verified + * (e.g. the user missed the system install prompt, or the periodic update check runs + * again before a pending install completed); or + * - **skips** entirely if the download is still active (queued/purchasing/downloading/ + * verifying), so the periodic [UpdateWorker] and repeated user taps can't reset it back + * to [DownloadStatus.QUEUED] and re-download it. + * + * A genuinely newer version, or a previously failed/cancelled download whose files are + * gone, falls through and is (re)enqueued. + */ + private suspend fun enqueue(download: Download) { + val existing = getDownload(download.packageName) + if (existing != null && existing.versionCode == download.versionCode) { + if (existing.canInstall(context)) { + Log.i(TAG, "${download.packageName} already downloaded, installing directly") + runCatching { + appInstaller.getPreferredInstaller(notifyOnFallback = true).install(existing) + }.onFailure { Log.e(TAG, "Failed to install ${download.packageName}", it) } + return + } + if (existing.isActive) { + Log.i( + TAG, + "Skipping enqueue for ${download.packageName}; already ${existing.status}" + ) + return + } + } + downloadDao.insert(download) + } + + /** + * Re-queues a previously failed (or otherwise inactive) download so it runs again. The + * worker resumes from any verified files on disk, so a retry after an install failure + * re-installs without re-downloading. No-op if the download is already active. + * @param packageName Name of the package to retry + */ + suspend fun retryDownload(packageName: String) { + val existing = getDownload(packageName) ?: return + if (existing.isActive) { + Log.i(TAG, "Skipping retry for $packageName; already ${existing.status}") + return + } + Log.i(TAG, "Retrying download for $packageName") + downloadDao.updateStatus(packageName, DownloadStatus.QUEUED) } /** @@ -118,6 +213,8 @@ class DownloadHelper @Inject constructor( suspend fun cancelDownload(packageName: String) { Log.i(TAG, "Cancelling download for $packageName") WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$packageName") + // Abandon any session already staged for install so we don't leak it. + runCatching { appInstaller.getPreferredInstaller().cancelInstall(packageName) } downloadDao.updateStatus(packageName, DownloadStatus.CANCELLED) } @@ -197,11 +294,24 @@ class DownloadHelper @Inject constructor( .putString(PACKAGE_NAME, download.packageName) .build() + // Require connectivity so the worker doesn't spin up (or keep running) without a + // network, and back off exponentially so transient failures resume cleanly once the + // connection returns instead of hammering the server. + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val work = OneTimeWorkRequestBuilder() .addTag(DOWNLOAD_WORKER) .addTag("$PACKAGE_NAME:${download.packageName}") .addTag("$VERSION_CODE:${download.versionCode}") .addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) .setInputData(inputData) .build() diff --git a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt index bb7f27db1..a4f92ed5b 100644 --- a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt @@ -14,8 +14,10 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import com.aurora.extensions.TAG import com.aurora.store.AuroraApp +import com.aurora.store.BuildConfig import com.aurora.store.data.event.BusEvent import com.aurora.store.data.event.InstallerEvent +import com.aurora.store.data.model.BuildType import com.aurora.store.data.model.UpdateMode import com.aurora.store.data.room.update.IgnoredUpdate import com.aurora.store.data.room.update.IgnoredUpdateDao @@ -185,6 +187,14 @@ class UpdateHelper @Inject constructor( updateDao.deleteAll() } + /** + * Immediately drops Aurora Store's own update row, e.g. when the user turns the + * self-update preference off. The periodic check won't re-add it while disabled. + */ + fun deleteSelfUpdate() { + AuroraApp.scope.launch { deleteUpdate(BuildConfig.APPLICATION_ID) } + } + /** * Hide all future updates for [packageName] until [unignore] is called. */ @@ -243,6 +253,13 @@ class UpdateHelper @Inject constructor( private suspend fun deleteInvalidUpdates() { updateDao.updates().firstOrNull()?.forEach { update -> + // Nightly builds share a static version code, so the generic "up to date" + // check would wrongly drop a freshly found self-update. The worker owns the + // self-update lifecycle (re-checked by build timestamp); cleanup happens via + // the install event instead. + if (update.isSelfUpdate(context) && BuildType.CURRENT == BuildType.NIGHTLY) { + return@forEach + } if (!update.isInstalled(context) || update.isUpToDate(context)) { deleteUpdate(update.packageName) } diff --git a/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt index 4917a6433..c5f73c51d 100644 --- a/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/AppInstaller.kt @@ -25,13 +25,17 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.util.Log import androidx.core.content.pm.PackageInfoCompat import com.aurora.Constants.PACKAGE_NAME_GMS +import com.aurora.extensions.TAG import com.aurora.extensions.getUpdateOwnerPackageNameCompat import com.aurora.extensions.isOAndAbove import com.aurora.extensions.isPAndAbove import com.aurora.extensions.isSAndAbove +import com.aurora.extensions.toast import com.aurora.store.BuildConfig +import com.aurora.store.R import com.aurora.store.data.installer.ShizukuInstaller.Companion.SHIZUKU_PACKAGE_NAME import com.aurora.store.data.installer.base.IInstaller import com.aurora.store.data.model.Installer @@ -172,8 +176,15 @@ class AppInstaller @Inject constructor( fun hasShizukuOrSui(context: Context): Boolean = isOAndAbove && (PackageUtil.isInstalled(context, SHIZUKU_PACKAGE_NAME) || Sui.isSui()) - fun hasShizukuPerm(): Boolean = - Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + // Shizuku.checkSelfPermission() throws when the binder is not alive (Shizuku + // disabled/not running), so guard on pingBinder() and swallow any failure to let + // callers fall back gracefully instead of crashing. + fun hasShizukuPerm(): Boolean = try { + Shizuku.pingBinder() && + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + } catch (_: Exception) { + false + } fun uninstall(context: Context, packageName: String) { val intent = Intent().apply { @@ -196,31 +207,47 @@ class AppInstaller @Inject constructor( fun getMicroGInstaller(): IInstaller = microGInstaller - fun getPreferredInstaller(): IInstaller = when (getCurrentInstaller(context)) { - Installer.SESSION -> sessionInstaller + /** + * Returns the installer for the user's chosen mode, transparently falling back to the + * default (session) installer when that mode is currently unavailable, e.g. the chosen + * Shizuku installer when Shizuku is disabled. + * @param notifyOnFallback Whether to inform the user via a toast when a fallback happens + */ + fun getPreferredInstaller(notifyOnFallback: Boolean = false): IInstaller { + val selected = getCurrentInstaller(context) + val installer = when (selected) { + Installer.SESSION -> sessionInstaller - Installer.NATIVE -> nativeInstaller + Installer.NATIVE -> nativeInstaller - Installer.ROOT -> if (hasRootAccess()) rootInstaller else defaultInstaller + Installer.ROOT -> if (hasRootAccess()) rootInstaller else defaultInstaller - Installer.SERVICE -> if (hasAuroraService(context)) { - serviceInstaller - } else { - defaultInstaller + Installer.SERVICE -> if (hasAuroraService(context)) { + serviceInstaller + } else { + defaultInstaller + } + + Installer.AM -> if (hasAppManager(context)) amInstaller else defaultInstaller + + Installer.SHIZUKU -> if (hasShizukuOrSui(context) && hasShizukuPerm()) { + shizukuInstaller + } else { + defaultInstaller + } + + Installer.MICROG -> if (hasMicroGInstaller(context)) { + microGInstaller + } else { + defaultInstaller + } } - Installer.AM -> if (hasAppManager(context)) amInstaller else defaultInstaller - - Installer.SHIZUKU -> if (hasShizukuOrSui(context) && hasShizukuPerm()) { - shizukuInstaller - } else { - defaultInstaller + if (selected != Installer.SESSION && installer === defaultInstaller) { + Log.i(TAG, "$selected installer unavailable, falling back to session installer") + if (notifyOnFallback) context.toast(R.string.installer_fallback_session) } - Installer.MICROG -> if (hasMicroGInstaller(context)) { - microGInstaller - } else { - defaultInstaller - } + return installer } } diff --git a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt index f3726d7f4..997fedebe 100644 --- a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt @@ -182,6 +182,16 @@ class SessionInstaller @Inject constructor( } } + override fun cancelInstall(packageName: String) { + val sessionSet = enqueuedSessions + .find { set -> set.any { it.packageName == packageName } } ?: return + + Log.i(TAG, "Abandoning staged session(s) for $packageName") + sessionSet.forEach { runCatching { packageInstaller.abandonSession(it.sessionId) } } + enqueuedSessions.remove(sessionSet) + removeFromInstallQueue(packageName) + } + private fun stageInstall( packageName: String, versionCode: Long, @@ -189,7 +199,12 @@ class SessionInstaller @Inject constructor( ): Int? { val resolvedPackageName = sharedLibPkgName.ifBlank { packageName } - val sessionParams = buildSessionParams(resolvedPackageName) + // Size hint lets the system reserve space (and evict its cache) for the staged copy. + val totalSize = runCatching { + getFiles(packageName, versionCode, sharedLibPkgName).sumOf { it.length() } + }.getOrDefault(0L) + + val sessionParams = buildSessionParams(resolvedPackageName, totalSize) val sessionId = packageInstaller.createSession(sessionParams) val session = packageInstaller.openSession(sessionId) @@ -216,9 +231,10 @@ class SessionInstaller @Inject constructor( } } - private fun buildSessionParams(packageName: String): SessionParams = + private fun buildSessionParams(packageName: String, totalSize: Long = 0L): SessionParams = SessionParams(SessionParams.MODE_FULL_INSTALL).apply { setAppPackageName(packageName) + if (totalSize > 0) setSize(totalSize) setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) if (isNAndAbove) { setOriginatingUid(Process.myUid()) diff --git a/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt index 2c832110f..10b02f4a6 100644 --- a/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt @@ -26,4 +26,11 @@ interface IInstaller { fun clearQueue() fun isAlreadyQueued(packageName: String): Boolean fun removeFromInstallQueue(packageName: String) + + /** + * Abandons any staged-but-uncommitted install session for [packageName] so cancelling + * a download doesn't leak a [android.content.pm.PackageInstaller] session. Default no-op + * for installers that don't stage sessions. + */ + fun cancelInstall(packageName: String) {} } diff --git a/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt b/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt index 035523af3..43ad80794 100644 --- a/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt +++ b/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt @@ -19,13 +19,11 @@ package com.aurora.store.data.installer.base -import android.app.NotificationManager import android.content.Context import android.content.pm.PackageInstaller import android.net.Uri import android.util.Log import androidx.core.content.FileProvider -import androidx.core.content.getSystemService import com.aurora.extensions.TAG import com.aurora.store.AuroraApp import com.aurora.store.BuildConfig @@ -42,13 +40,7 @@ abstract class InstallerBase(private val context: Context) : IInstaller { companion object { fun notifyInstallation(context: Context, displayName: String, packageName: String) { - val notificationManager = context.getSystemService() - val notification = NotificationUtil.getInstallNotification( - context, - displayName, - packageName - ) - notificationManager!!.notify(packageName.hashCode(), notification) + NotificationUtil.notifyInstalled(context, displayName, packageName) } fun getErrorString(context: Context, status: Int): String = when (status) { diff --git a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt index da3dada10..f25ea7d52 100644 --- a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt +++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt @@ -11,10 +11,20 @@ enum class DownloadStatus(@StringRes val localized: Int) { QUEUED(R.string.status_queued), UNAVAILABLE(R.string.status_unavailable), VERIFYING(R.string.status_verifying), - PURCHASING(R.string.preparing_to_install); + PURCHASING(R.string.preparing_to_install), + INSTALLING(R.string.status_installing), + INSTALLED(R.string.status_installed); companion object { - val finished = listOf(FAILED, CANCELLED, COMPLETED) - val running = listOf(QUEUED, PURCHASING, DOWNLOADING) + val finished = setOf(FAILED, CANCELLED, COMPLETED, INSTALLED) + val running = setOf(QUEUED, PURCHASING, DOWNLOADING) + + /** + * States in which a download worker is actively occupying the (single) download + * slot — purchasing, transferring bytes or verifying. Used to serialize downloads: + * the next [QUEUED] item is only started once none of these are in progress, so + * concurrent workers can't clobber the shared foreground/progress notification. + */ + val processing = setOf(PURCHASING, DOWNLOADING, VERIFYING) } } diff --git a/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt b/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt index 37e4a4e2a..4a589214f 100644 --- a/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt +++ b/app/src/main/java/com/aurora/store/data/model/SelfUpdate.kt @@ -1,20 +1,6 @@ /* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later */ package com.aurora.store.data.model @@ -24,62 +10,79 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Artwork import com.aurora.gplayapi.data.models.EncodedCertificateSet import com.aurora.gplayapi.data.models.PlayFile -import com.aurora.store.BuildConfig import com.aurora.store.R import com.aurora.store.util.CertUtil import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * Self-update feed entry returned by `release_feed.json` (vanilla release) and + * `nightly_feed.json` (nightly), both served from the Aurora OSS server. + * + * The producer encodes numeric fields as JSON strings, so the raw fields below stay + * `String` and we expose typed `Long` accessors that tolerate blank values. Decoding + * with `Long`s would otherwise blow up on the first request. + */ @Serializable data class SelfUpdate( - @SerialName("version_name") var versionName: String = String(), - @SerialName("version_code") var versionCode: Long = 0, - @SerialName("aurora_build") var auroraBuild: String = String(), - @SerialName("fdroid_build") var fdroidBuild: String = String(), - @SerialName("updated_on") var updatedOn: String = String(), - val changelog: String = String(), - val size: Long = 0L, - val timestamp: Long = 0L + @SerialName("version_name") + val versionName: String = "", + @SerialName("version_code") + val versionCodeRaw: String = "0", + @SerialName("download_url") + val downloadUrl: String = "", + val changelog: String = "", + @SerialName("size") + val sizeRaw: String = "0", + @SerialName("last_commit") + val lastCommit: String = "", + @SerialName("updated_on") + val updatedOn: String = "", + @SerialName("timestamp") + val timestampRaw: String = "0" ) { - companion object { - private const val BASE_URL = "https://gitlab.com/AuroraOSS/AuroraStore/-/raw/master" + val versionCode: Long get() = versionCodeRaw.toLongOrNull() ?: 0L + val size: Long get() = sizeRaw.toLongOrNull() ?: 0L + val timestamp: Long get() = timestampRaw.toLongOrNull() ?: 0L - fun toApp(selfUpdate: SelfUpdate, context: Context): App { - // Keep paths updated with fastlane data on project - val icon = "fastlane/metadata/android/en-US/images/icon.png" - - val downloadURL = if (CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID)) { - selfUpdate.fdroidBuild - } else { - selfUpdate.auroraBuild - } - - return App( - packageName = context.packageName, - versionCode = selfUpdate.versionCode, - versionName = selfUpdate.versionName, - changes = selfUpdate.changelog, - size = selfUpdate.size, - updatedOn = selfUpdate.updatedOn, - displayName = context.getString(R.string.app_name), - developerName = "Rahul Kumar Patel", - iconArtwork = Artwork(url = "$BASE_URL/$icon"), - fileList = mutableListOf( - PlayFile( - name = "${context.packageName}.apk", - url = downloadURL, - size = selfUpdate.size - ) - ), - isFree = true, - isInstalled = true, - certificateSetList = CertUtil.getEncodedCertificateHashes( - context, - context.packageName - ).map { - EncodedCertificateSet(certificateSet = it, sha256 = String()) - }.toMutableList() + /** + * Maps the feed entry onto a regular [App] so it can flow through the normal + * update pipeline ([com.aurora.store.data.room.update.Update.fromApp] → + * download → install). The certificate set is the currently installed app's own + * hashes so [com.aurora.store.data.room.update.Update.hasValidCert] holds and the + * update is never filtered out as untrusted. + */ + fun toApp(context: Context): App = App( + packageName = context.packageName, + versionCode = versionCode, + versionName = versionName, + changes = changelog, + size = size, + updatedOn = updatedOn, + displayName = context.getString(R.string.app_name), + developerName = "Rahul Kumar Patel", + iconArtwork = Artwork(url = ICON_URL), + fileList = mutableListOf( + PlayFile( + name = "${context.packageName}.apk", + url = downloadUrl, + size = size ) - } + ), + isFree = true, + isInstalled = true, + certificateSetList = CertUtil.getEncodedCertificateHashes( + context, + context.packageName + ).map { + EncodedCertificateSet(certificateSet = it, sha256 = String()) + }.toMutableList() + ) + + companion object { + // Kept in sync with the fastlane metadata on the project. + private const val ICON_URL = + "https://gitlab.com/AuroraOSS/AuroraStore/-/raw/master/" + + "fastlane/metadata/android/en-US/images/icon.png" } } diff --git a/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt b/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt index 2e42dbf6d..f5ad0031c 100644 --- a/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt +++ b/app/src/main/java/com/aurora/store/data/receiver/BaseInstallerStatusReceiver.kt @@ -18,7 +18,6 @@ */ package com.aurora.store.data.receiver -import android.app.NotificationManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -26,7 +25,6 @@ import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller.EXTRA_SESSION_ID import android.util.Log import androidx.core.content.IntentCompat -import androidx.core.content.getSystemService import com.aurora.extensions.TAG import com.aurora.extensions.runOnUiThread import com.aurora.store.AuroraApp @@ -92,14 +90,12 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() { displayName: String, status: Int ) { - val notificationManager = context.getSystemService() - val notification = NotificationUtil.getInstallerStatusNotification( + NotificationUtil.notifyInstallFailed( context, packageName, displayName, InstallerBase.getErrorString(context, status) ) - notificationManager?.notify(packageName.hashCode(), notification) } internal fun promptUser(context: Context, intent: Intent) { diff --git a/app/src/main/java/com/aurora/store/data/receiver/DownloadRetryReceiver.kt b/app/src/main/java/com/aurora/store/data/receiver/DownloadRetryReceiver.kt new file mode 100644 index 000000000..15a0759c4 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/receiver/DownloadRetryReceiver.kt @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.aurora.extensions.TAG +import com.aurora.store.AuroraApp +import com.aurora.store.data.helper.DownloadHelper +import com.aurora.store.util.NotificationUtil +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Re-queues a previously failed download/install when the user taps "Retry" on its + * (grouped) failure notification. + */ +@AndroidEntryPoint +class DownloadRetryReceiver : BroadcastReceiver() { + + @Inject + lateinit var downloadHelper: DownloadHelper + + override fun onReceive(context: Context, intent: Intent?) { + val packageName = intent?.getStringExtra(DownloadHelper.PACKAGE_NAME) ?: "" + + if (packageName.isNotBlank()) { + Log.d(TAG, "Received retry request for $packageName") + // Clear the failure notification immediately for responsive feedback; the + // download worker re-posts progress once it runs. + NotificationUtil.clearAppNotification(context, packageName) + AuroraApp.scope.launch(Dispatchers.IO) { + downloadHelper.retryDownload(packageName) + } + } + } +} diff --git a/app/src/main/java/com/aurora/store/data/room/download/Download.kt b/app/src/main/java/com/aurora/store/data/room/download/Download.kt index 5a2ef3f40..079ae8fac 100644 --- a/app/src/main/java/com/aurora/store/data/room/download/Download.kt +++ b/app/src/main/java/com/aurora/store/data/room/download/Download.kt @@ -43,6 +43,13 @@ data class Download( val isRunning get() = status in DownloadStatus.running private val isSuccessful get() = status == DownloadStatus.COMPLETED + /** + * `true` while the download is queued, purchasing, downloading or verifying, i.e. + * the pipeline is actively working on it. Unlike [isRunning] this also covers + * [DownloadStatus.VERIFYING], which sits between downloading and completion. + */ + val isActive get() = isRunning || status == DownloadStatus.VERIFYING + companion object { fun fromApp(app: App): Download = Download( app.packageName, @@ -108,7 +115,10 @@ data class Download( } fun canInstall(context: Context): Boolean { + if (!isSuccessful) return false val dir = PathUtil.getAppDownloadDir(context, packageName, versionCode) - return isSuccessful && dir.listFiles() != null + // Require at least one actual APK on disk, not just that the directory exists — + // an empty/partially-cleaned directory must not look installable. + return dir.listFiles()?.any { it.name.endsWith(".apk") } == true } } diff --git a/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt b/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt index 076f0db54..5e6fcb60c 100644 --- a/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt @@ -8,6 +8,8 @@ import androidx.work.ExistingPeriodicWorkPolicy.KEEP import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.aurora.store.data.model.DownloadStatus +import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.util.PathUtil import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -17,12 +19,14 @@ import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.MINUTES import kotlin.time.DurationUnit import kotlin.time.toDuration +import kotlinx.coroutines.flow.first /** * A periodic worker to automatically clear the old downloads cache periodically. */ @HiltWorker class CacheWorker @AssistedInject constructor( + private val downloadDao: DownloadDao, @Assisted private val context: Context, @Assisted workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { @@ -58,6 +62,17 @@ class CacheWorker @AssistedInject constructor( override suspend fun doWork(): Result { Log.i(TAG, "Cleaning cache") + // Files for downloads that are still in-flight or downloaded & awaiting install + // must be protected from the age-based purge, otherwise a download the user hasn't + // installed yet (e.g. they missed the system prompt) would lose its files and have + // to be re-downloaded. Keyed by packageName -> versionCode. + val protectedVersions = runCatching { + downloadDao.downloads().first() + .filter { it.isActive || it.status == DownloadStatus.COMPLETED } + .map { it.packageName to it.versionCode } + .toSet() + }.getOrDefault(emptySet()) + PathUtil.getOldDownloadDirectories(context).filter { it.exists() }.forEach { dir -> // Downloads Log.i(TAG, "Deleting old unused download directory: $dir") @@ -75,10 +90,14 @@ class CacheWorker @AssistedInject constructor( download.listFiles()!!.forEach { versionCode -> // 20240325 + val isProtected = (download.name to versionCode.name.toLongOrNull()) in + protectedVersions if (versionCode.listFiles().isNullOrEmpty()) { // Purge empty non-accessible directory Log.i(TAG, "Removing empty directory for ${download.name}, ${versionCode.name}") versionCode.deleteRecursively() + } else if (isProtected) { + Log.i(TAG, "Keeping ${download.name} (${versionCode.name}); install pending") } else { versionCode.deleteIfOld() } diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index cc8319ccf..b1a3934f8 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -11,6 +11,7 @@ import android.content.Context import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.storage.StorageManager import android.util.Log import androidx.core.content.getSystemService import androidx.core.graphics.scale @@ -21,6 +22,7 @@ import androidx.work.WorkInfo.Companion.STOP_REASON_USER import androidx.work.WorkerParameters import com.aurora.extensions.TAG import com.aurora.extensions.copyTo +import com.aurora.extensions.isOAndAbove import com.aurora.extensions.isPAndAbove import com.aurora.extensions.isQAndAbove import com.aurora.extensions.isSAndAbove @@ -44,10 +46,16 @@ import com.aurora.store.util.CertUtil import com.aurora.store.util.NotificationUtil import com.aurora.store.util.PackageUtil import com.aurora.store.util.PathUtil +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_NOTIFICATION_PROGRESS import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.io.File import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection.HTTP_FORBIDDEN +import java.net.HttpURLConnection.HTTP_GONE +import java.net.HttpURLConnection.HTTP_PARTIAL import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException @@ -77,17 +85,31 @@ class DownloadWorker @AssistedInject constructor( companion object { private const val NOTIFICATION_ID: Int = 200 + + // Upper bound on automatic WorkManager retries for transient (network) failures + // before the download is marked as failed and left for the user to retry. + private const val MAX_DOWNLOAD_RETRIES = 5 } private lateinit var download: Download private val notificationManager = context.getSystemService()!! + // When the user opts out of progress notifications, the mandatory foreground notification + // is kept minimal and per-tick progress refreshes are skipped. Read live so toggling the + // setting takes effect on the next download. + private val showProgress: Boolean + get() = Preferences.getBoolean(context, PREFERENCE_NOTIFICATION_PROGRESS, true) + private var icon: Bitmap? = null private var totalBytes by Delegates.notNull() private var totalProgress = 0 private var downloadedBytes = 0L + // Absolute paths of files already verified during the download pass, so the final + // verification gate doesn't hash large APKs a second time. + private val verifiedFiles = mutableSetOf() + inner class NoNetworkException : Exception(context.getString(R.string.title_no_network)) inner class NothingToDownloadException : Exception(context.getString(R.string.purchase_no_file)) inner class DownloadFailedException : Exception(context.getString(R.string.download_failed)) @@ -97,6 +119,11 @@ class DownloadWorker @AssistedInject constructor( inner class VerificationFailedException : Exception(context.getString(R.string.verification_failed)) + inner class ExpiredUrlException : Exception(context.getString(R.string.download_failed)) + + inner class InsufficientStorageException : + Exception(context.getString(R.string.download_failed_storage)) + override suspend fun doWork(): Result { super.doWork() @@ -173,6 +200,15 @@ class DownloadWorker @AssistedInject constructor( downloadDao.updateFiles(download.packageName, download.fileList) downloadDao.updateSharedLibs(download.packageName, download.sharedLibs) + // Fail fast (and let the system free its cache) if there isn't room for the download, + // instead of dying mid-write with a partial file. Only the not-yet-downloaded bytes + // need to fit. + try { + ensureStorageAvailable(totalBytes - downloadedBytesOnDisk(files)) + } catch (exception: Exception) { + return onFailure(exception) + } + // Download files try { for (file in files) { @@ -184,24 +220,34 @@ class DownloadWorker @AssistedInject constructor( download.downloadedFiles++ } } catch (exception: Exception) { - if (exception is DownloadCancelledException) { - Log.i(TAG, "Download cancelled for ${download.packageName}") - // Try to delete all downloaded files + // Only purge partial files on a genuine user/app cancellation. A stop caused + // by lost connectivity, quota or device-state must keep the partials so the + // retry resumes instead of re-downloading from scratch (this was the source of + // downloads appearing to "restart" on a flaky network). + if (exception is DownloadCancelledException && isCancelledByUser()) { + Log.i(TAG, "Download cancelled by user for ${download.packageName}") runCatching { files.forEach { deleteFile(it) } } } return onFailure(exception) } - // Report failure if download was stopped or failed - if (isStopped) return onFailure(DownloadFailedException()) + // A stop that isn't a user cancellation (e.g. connectivity constraint) should be + // retried with the partials intact rather than treated as a hard failure. + if (isStopped) return onFailure(DownloadCancelledException()) - // Verify downloaded files + // Verify downloaded files (skipping any already verified during the download pass) try { notifyStatus(DownloadStatus.VERIFYING) - files.forEach { file -> require(verifyFile(file)) } + files.forEach { file -> + val path = PathUtil.getLocalFile(context, file, download).absolutePath + if (path !in verifiedFiles) require(verifyFile(file)) + } } catch (exception: Exception) { Log.e(TAG, "Failed to verify ${download.packageName}", exception) + // Drop the corrupt files so the next attempt re-downloads them clean instead + // of resuming from a poisoned offset. + runCatching { files.forEach { deleteFile(it) } } return onFailure(VerificationFailedException()) } @@ -214,7 +260,11 @@ class DownloadWorker @AssistedInject constructor( private suspend fun onSuccess(): Result { return withContext(NonCancellable) { return@withContext try { - appInstaller.getPreferredInstaller().install(download) + // Update the ongoing foreground notification to reflect the install phase, + // so the user sees a clean "Downloading -> Installing" progression instead of + // a stale download bar lingering at 100%. + notifyStatus(DownloadStatus.INSTALLING, isProgress = true) + appInstaller.getPreferredInstaller(notifyOnFallback = true).install(download) Result.success() } catch (exception: Exception) { Log.e(TAG, "Failed to install ${download.packageName}", exception) @@ -223,12 +273,60 @@ class DownloadWorker @AssistedInject constructor( } } + /** + * Whether the current stop/cancellation was initiated by the user (or the app on the + * user's behalf) rather than by the system (connectivity/quota/device-state). Uses the + * S+ [stopReason] when available and otherwise falls back to the persisted status, which + * [DownloadHelper.cancelDownload] sets to [DownloadStatus.CANCELLED] before cancelling + * the work — making this reliable below Android 12 too. + */ + private suspend fun isCancelledByUser(): Boolean { + val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP) + if (isSAndAbove && stopReason in cancelReasons) return true + return runCatching { + downloadDao.getDownload(download.packageName).status == DownloadStatus.CANCELLED + }.getOrDefault(false) + } + + /** + * Transient errors worth retrying once connectivity returns. Walks the cause chain so a + * wrapped network error is still recognised. + */ + private fun isRetryable(throwable: Throwable?): Boolean = when (throwable) { + null -> false + is NoNetworkException, + is SocketException, + is SocketTimeoutException, + is UnknownHostException, + // Expired URLs were re-purchased by clearing the file list; retrying re-fetches them. + is ExpiredUrlException -> true + + else -> isRetryable(throwable.cause) + } + private suspend fun onFailure(exception: Exception): Result { return withContext(NonCancellable) { Log.i(TAG, "Job failed: ${download.packageName}", exception) - val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP) - if (isSAndAbove && stopReason in cancelReasons) { + val cancelledByUser = isCancelledByUser() + + // Retry transient failures (lost connectivity, system-initiated stops) with + // backoff, keeping any partial download for resume. The network constraint on + // the work request means the retry only runs once connectivity is back. + val isSystemStop = exception is DownloadCancelledException && !cancelledByUser + if (!cancelledByUser && + (isRetryable(exception) || isSystemStop) && + runAttemptCount < MAX_DOWNLOAD_RETRIES + ) { + Log.w( + TAG, + "Transient failure for ${download.packageName}, " + + "retrying (attempt $runAttemptCount)" + ) + return@withContext Result.retry() + } + + if (cancelledByUser) { notifyStatus(DownloadStatus.CANCELLED) } else { when (exception) { @@ -297,28 +395,63 @@ class DownloadWorker @AssistedInject constructor( if (file.exists() && verifyFile(gFile)) { Log.i(TAG, "$file is already downloaded!") downloadedBytes += file.length() + verifiedFiles.add(file.absolutePath) return@withContext true } try { - val tmpFileSuffix = ".tmp" - val tmpFile = File(file.absolutePath + tmpFileSuffix) - // Download as a temporary file to avoid installing corrupted files - val isNewFile = tmpFile.createNewFile() + val tmpFile = File(file.absolutePath + ".tmp") + val existingBytes = if (tmpFile.exists()) tmpFile.length() else 0L val okHttpClient = httpClient as HttpClient val headers = mutableMapOf() - - if (!isNewFile) { - Log.i(TAG, "$tmpFile has an unfinished download, resuming!") - downloadedBytes += tmpFile.length() - headers["Range"] = "bytes=${tmpFile.length()}-" + if (existingBytes > 0) { + Log.i(TAG, "$tmpFile has an unfinished download, requesting resume!") + headers["Range"] = "bytes=$existingBytes-" } - okHttpClient.call(gFile.url, headers).body.byteStream().use { input -> - FileOutputStream(tmpFile, !isNewFile).use { - input.copyTo(it, gFile.size).collect { info -> onProgress(info) } + val response = okHttpClient.call(gFile.url, headers) + if (!response.isSuccessful) { + val code = response.code + response.close() + // Play download URLs are short-lived; a 403/410 means ours expired while + // the download sat queued. Drop the stale file lists so the retry + // re-purchases fresh URLs instead of hammering the dead one. + if (code == HTTP_FORBIDDEN || code == HTTP_GONE) { + Log.w(TAG, "Download URL for ${download.packageName} expired (code=$code)") + downloadDao.updateFiles(download.packageName, emptyList()) + downloadDao.updateSharedLibs( + download.packageName, + download.sharedLibs.map { it.copy(fileList = emptyList()) } + ) + throw ExpiredUrlException() + } + throw DownloadFailedException() + } + + // Only resume when the server actually honored the Range request (206). If + // it replied 200 with the full body we must overwrite from the start, + // otherwise the full payload would be appended onto the existing partial and + // silently corrupt the file. + val resuming = existingBytes > 0 && response.code == HTTP_PARTIAL + if (resuming) { + downloadedBytes += existingBytes + } else if (existingBytes > 0) { + Log.w( + TAG, + "Server ignored Range for $tmpFile (code=${response.code}), restarting" + ) + } + + response.body.byteStream().use { input -> + FileOutputStream(tmpFile, resuming).use { + input.copyTo(it, gFile.size).collect { info -> + // Abort promptly mid-file when stopped, instead of only checking + // between files (a single split can be hundreds of MB). + if (isStopped) throw CancellationException("Download stopped") + onProgress(info) + } } } @@ -389,7 +522,7 @@ class DownloadWorker @AssistedInject constructor( } override suspend fun getForegroundInfo(): ForegroundInfo { - val notification = if (this::download.isInitialized) { + val notification = if (this::download.isInitialized && showProgress) { NotificationUtil.getDownloadNotification(context, download, icon) } else { NotificationUtil.getDownloadNotification(context) @@ -416,18 +549,44 @@ class DownloadWorker @AssistedInject constructor( downloadDao.updateStatus(download.packageName, status) when (status) { + // Internal phases the user doesn't need a separate notification for: the ongoing + // foreground progress notification already conveys that work is in progress. + // Clear any stale per-app notification (e.g. a prior failure being retried) so it + // doesn't linger. + DownloadStatus.PURCHASING, DownloadStatus.VERIFYING, - DownloadStatus.CANCELLED -> return + DownloadStatus.CANCELLED -> { + notificationManager.cancel(download.packageName.hashCode()) + return + } DownloadStatus.COMPLETED -> { // Mark progress as 100 manually to avoid race conditions download.progress = 100 downloadDao.updateProgress(download.packageName, 100, 0, 0) + + // Silently-installable apps install automatically and get a single + // "installed" notification afterwards, so a separate "download complete" + // notice is just noise. Only surface completion when the user must act on it + // (tap to install). + val needsUserAction = !AppInstaller.canInstallSilently( + context, + download.packageName, + download.targetSdk + ) + if (!needsUserAction) { + notificationManager.cancel(download.packageName.hashCode()) + return + } } else -> {} } + // Skip detailed progress refreshes when the user has hidden progress; the minimal + // foreground notification posted via getForegroundInfo keeps the download alive. + if (isProgress && !showProgress) return + val notification = NotificationUtil.getDownloadNotification( context, download, @@ -438,6 +597,12 @@ class DownloadWorker @AssistedInject constructor( if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(), notification ) + + // A failed download is a grouped child; reconcile the failure summary so a bulk + // update collapses into a single "N apps failed" entry. + if (status == DownloadStatus.FAILED) { + NotificationUtil.refreshGroupSummaries(context) + } } /** @@ -452,6 +617,10 @@ class DownloadWorker @AssistedInject constructor( val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256 val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256 + if (algorithm == Algorithm.SHA1) { + Log.w(TAG, "No SHA-256 for ${gFile.name}, falling back to SHA-1") + } + if (expectedSha.isBlank()) return false return withContext(Dispatchers.IO) { @@ -489,4 +658,44 @@ class DownloadWorker @AssistedInject constructor( Log.i(TAG, "Deleted Temp: $tmpFile") } } + + /** + * Bytes already present on disk (final or partial .tmp) for [files], so the storage check + * only requires room for what's still left to fetch. + */ + private fun downloadedBytesOnDisk(files: List): Long = files.sumOf { gFile -> + val file = PathUtil.getLocalFile(context, gFile, download) + val tmpFile = File(file.absolutePath + ".tmp") + when { + file.exists() -> file.length() + tmpFile.exists() -> tmpFile.length() + else -> 0L + } + } + + /** + * Ensures there's room for [requiredBytes] before downloading, throwing + * [InsufficientStorageException] otherwise. On Android O+ this also asks the system to + * evict its own cache to make space, per the storage guidelines. + */ + private fun ensureStorageAvailable(requiredBytes: Long) { + if (requiredBytes <= 0) return + + val dir = PathUtil.getDownloadDirectory(context).apply { mkdirs() } + if (isOAndAbove) { + val storageManager = context.getSystemService()!! + try { + val uuid = storageManager.getUuidForPath(dir) + if (storageManager.getAllocatableBytes(uuid) < requiredBytes) { + throw InsufficientStorageException() + } + storageManager.allocateBytes(uuid, requiredBytes) + } catch (exception: IOException) { + Log.e(TAG, "Failed to allocate space for ${download.packageName}", exception) + throw InsufficientStorageException() + } + } else if (dir.usableSpace < requiredBytes) { + throw InsufficientStorageException() + } + } } diff --git a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt index 21740d6ac..a08e1a151 100644 --- a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt @@ -7,24 +7,30 @@ package com.aurora.store.data.work import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.net.Uri +import android.provider.DocumentsContract import android.util.Log import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.aurora.extensions.isQAndAbove import com.aurora.store.data.room.download.Download +import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.util.NotificationUtil import com.aurora.store.util.PathUtil import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.io.File +import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -34,7 +40,8 @@ import java.util.zip.ZipOutputStream @HiltWorker class ExportWorker @AssistedInject constructor( @Assisted private val context: Context, - @Assisted workerParams: WorkerParameters + @Assisted workerParams: WorkerParameters, + private val downloadDao: DownloadDao ) : CoroutineWorker(context, workerParams) { companion object { @@ -48,6 +55,9 @@ class ExportWorker @AssistedInject constructor( private const val NOTIFICATION_ID = 500 private const val NOTIFICATION_ID_FGS = 501 + // Large copy buffer to keep throughput high when bundling multi-GB exports + private const val BUFFER_SIZE = 1024 * 1024 + /** * Exports the given download to the URI * @param download Download to export @@ -67,7 +77,12 @@ class ExportWorker @AssistedInject constructor( .build() Log.i(TAG, "Exporting download for ${download.packageName}/${download.versionCode}") - WorkManager.getInstance(context).enqueue(oneTimeWorkRequest) + // Keep any in-progress export for the same app so re-tapping does not queue duplicates + WorkManager.getInstance(context).enqueueUniqueWork( + "$TAG/${download.packageName}", + ExistingWorkPolicy.KEEP, + oneTimeWorkRequest + ) } } @@ -83,26 +98,59 @@ class ExportWorker @AssistedInject constructor( if (packageName.isNullOrEmpty() || versionCode == -1L) { Log.e(TAG, "Input data is corrupt, bailing out!") - notifyStatus(displayName ?: String(), uri, false) - return Result.failure() + return fail(displayName ?: String(), uri) } try { - copyDownloadedApp(packageName, versionCode, uri) + val download = downloadDao.getDownload(packageName) + + // The download row is keyed by package name only, so guard against it having + // been replaced by a manual download of a different version between enqueue and + // execution; the old version's file list is no longer available to export. + if (download.versionCode != versionCode) { + Log.e(TAG, "Download for $packageName is no longer version $versionCode, bailing!") + return fail(displayName ?: packageName, uri) + } + + val entries = PathUtil.getExportableFiles(context, download) + if (entries.isEmpty()) { + // Nothing on disk to bundle, e.g. files were auto-deleted after install + Log.e(TAG, "No files to export for $packageName, was it auto-deleted?") + return fail(displayName ?: packageName, uri) + } + + bundleFiles(displayName ?: packageName, entries, uri) notifyStatus(displayName ?: packageName, uri) } catch (exception: Exception) { Log.e(TAG, "Failed to export $packageName", exception) - notifyStatus(displayName ?: packageName, uri, false) - return Result.failure() + return fail(displayName ?: packageName, uri) } return Result.success() } - override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo( - NOTIFICATION_ID_FGS, - NotificationUtil.getExportNotification(context) - ) + override suspend fun getForegroundInfo(): ForegroundInfo = + exportForegroundInfo(inputData.getString(DISPLAY_NAME)) + + private fun exportForegroundInfo(displayName: String?, progress: Int = -1): ForegroundInfo { + val notification = NotificationUtil.getExportNotification(context, displayName, progress) + return if (isQAndAbove) { + ForegroundInfo(NOTIFICATION_ID_FGS, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIFICATION_ID_FGS, notification) + } + } + + /** + * Deletes the partially written (or empty) export document and notifies failure, so a + * failed export does not leave a corrupt zip behind at the user-chosen location. + */ + private fun fail(packageName: String, uri: Uri): Result { + runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) } + .onFailure { Log.w(TAG, "Failed to delete incomplete export at $uri", it) } + notifyStatus(packageName, uri, false) + return Result.failure() + } private fun notifyStatus(packageName: String, uri: Uri, success: Boolean = true) { notificationManager.notify( @@ -116,23 +164,49 @@ class ExportWorker @AssistedInject constructor( ) } - private fun copyDownloadedApp(packageName: String, versionCode: Long, uri: Uri) = bundleAllAPKs( - PathUtil.getAppDownloadDir(context, packageName, versionCode).listFiles()!!.toList(), - uri - ) + /** + * Updates the ongoing foreground notification to reflect export [progress] (0-100). + */ + private suspend fun notifyProgress(displayName: String, progress: Int) { + setForeground(exportForegroundInfo(displayName, progress)) + } /** - * Bundles all the given APKs to a zip file - * @param fileList List of APKs to add to the zip - * @param uri [Uri] of the file to write the APKs + * Bundles the given files into a zip written to the [uri], preserving each file's + * relative path within the archive so shared libraries and OBB/patch files retain + * their layout, while reporting progress through the foreground notification. + * + * Entries are stored without compression: APKs and OBBs are already compressed, so + * deflating them wastes CPU for no size gain and makes large bundles needlessly slow. + * @param displayName App name shown in the progress notification + * @param entries Map of zip entry name to the file to write + * @param uri [Uri] of the file to write the bundle to */ - private fun bundleAllAPKs(fileList: List, uri: Uri) { - ZipOutputStream(context.contentResolver.openOutputStream(uri)).use { zipOutput -> - fileList.forEach { file -> - file.inputStream().use { input -> - val zipEntry = ZipEntry(file.name) - zipOutput.putNextEntry(zipEntry) - input.copyTo(zipOutput) + private suspend fun bundleFiles(displayName: String, entries: Map, uri: Uri) { + val totalBytes = entries.values.sumOf { it.length() }.coerceAtLeast(1) + var writtenBytes = 0L + var lastProgress = -1 + + val output = context.contentResolver.openOutputStream(uri)!!.buffered(BUFFER_SIZE) + ZipOutputStream(output).use { zipOutput -> + zipOutput.setLevel(Deflater.NO_COMPRESSION) + val buffer = ByteArray(BUFFER_SIZE) + entries.forEach { (entryName, file) -> + file.inputStream().buffered(BUFFER_SIZE).use { input -> + zipOutput.putNextEntry(ZipEntry(entryName)) + var read = input.read(buffer) + while (read >= 0) { + zipOutput.write(buffer, 0, read) + writtenBytes += read + + // Throttle to whole-percent changes to avoid being rate-limited + val progress = ((writtenBytes * 100) / totalBytes).toInt() + if (progress > lastProgress) { + lastProgress = progress + notifyProgress(displayName, progress) + } + read = input.read(buffer) + } zipOutput.closeEntry() } } diff --git a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt index f32a14527..7275ff99f 100644 --- a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt @@ -15,7 +15,6 @@ import com.aurora.extensions.isHyperOS import com.aurora.extensions.isIgnoringBatteryOptimizations import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.helpers.AppDetailsHelper -import com.aurora.gplayapi.network.IHttpClient import com.aurora.store.BuildConfig import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.UpdateHelper @@ -23,6 +22,7 @@ import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.BuildType import com.aurora.store.data.model.SelfUpdate import com.aurora.store.data.model.UpdateMode +import com.aurora.store.data.network.HttpClient import com.aurora.store.data.providers.AccountProvider import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.BlacklistProvider @@ -32,6 +32,7 @@ import com.aurora.store.util.CertUtil import com.aurora.store.util.NotificationUtil import com.aurora.store.util.PackageUtil import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_SELF_UPDATE_ENABLED import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_AUTO import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -41,18 +42,22 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json /** - * A worker to check for updates for installed apps based on saved authentication data, - * filters and the auto-updates mode selected by the user. The repeat interval - * is configurable by the user, defaulting to 3 hours with a flex time of 30 minutes. + * A worker that drives periodic app-update checks. The repeat interval is configurable + * by the user, defaulting to 3 hours with a flex time of 30 minutes. + * + * Aurora Store's own update is fetched from the bundled release/nightly feed and added + * to the regular update list (see [getSelfUpdate]); from there it reuses the standard + * download + install pipeline. It is never auto-installed silently — the user triggers + * it from the Updates tab. * * Avoid using this worker directly and prefer using [UpdateHelper] instead. * @see AuthWorker */ @HiltWorker class UpdateWorker @AssistedInject constructor( + private val httpClient: HttpClient, private val json: Json, private val blacklistProvider: BlacklistProvider, - private val httpClient: IHttpClient, private val updateDao: UpdateDao, private val downloadHelper: DownloadHelper, private val authProvider: AuthProvider, @@ -61,11 +66,18 @@ class UpdateWorker @AssistedInject constructor( @Assisted workerParams: WorkerParameters ) : AuthWorker(authProvider, context, workerParams) { - private val notificationID = 100 + companion object { + private const val NOTIFICATION_ID = 100 + } - private val canSelfUpdate = !CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID) && - !CertUtil.isAppGalleryApp(context, BuildConfig.APPLICATION_ID) && - BuildType.CURRENT != BuildType.DEBUG + /** + * `true` when the build supports self-update ([PackageUtil.isSelfUpdateSupported]) + * and the user hasn't opted out via the Settings toggle. Read each check so flipping + * the preference takes effect on the next run. + */ + private val canSelfUpdate: Boolean + get() = PackageUtil.isSelfUpdateSupported(context) && + Preferences.getBoolean(context, PREFERENCE_SELF_UPDATE_ENABLED, true) private val isAuroraOnlyFilterEnabled: Boolean get() = Preferences.getBoolean(context, Preferences.PREFERENCE_FILTER_AURORA_ONLY, false) @@ -124,7 +136,8 @@ class UpdateWorker @AssistedInject constructor( return Result.success() } - // Clean the update list to prepare for installing + // Clean the update list to prepare for installing. Aurora Store's own update + // is never installed silently — the user triggers it from the Updates tab. val filteredUpdates = updates .filter { it.hasValidCert } .filterNot { it.isSelfUpdate(context) } @@ -152,7 +165,7 @@ class UpdateWorker @AssistedInject constructor( } override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo( - notificationID, + NOTIFICATION_ID, NotificationUtil.getUpdateNotification(context) ) @@ -206,7 +219,18 @@ class UpdateWorker @AssistedInject constructor( .filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) } .toMutableList() - if (canSelfUpdate) getSelfUpdate()?.let { updates.add(it) } + // Aurora Store's own update comes from the feed, not Play. When one is + // offered, add it; otherwise drop any stale self-update row. This is the + // cleanup path for the row (nightly self-updates are exempt from + // deleteInvalidUpdates, and the install event isn't delivered reliably when + // the app replaces itself), so a previously shown self-update doesn't linger + // after we've already updated to it. + val selfUpdate = if (canSelfUpdate) getSelfUpdate() else null + if (selfUpdate != null) { + updates.add(selfUpdate) + } else { + updateDao.delete(context.packageName) + } return@withContext updates.map { Update.fromApp( @@ -219,58 +243,47 @@ class UpdateWorker @AssistedInject constructor( } /** - * Checks and returns updates for Aurora Store if available + * Fetches Aurora Store's own update from the bundled release/nightly feed and maps + * it onto an [App] so it joins the regular update list. Nightly version codes never + * bump, so newness is decided by the build timestamp there; release uses the version + * code. Best-effort: any failure logs and yields no update. */ - private suspend fun getSelfUpdate(): App? { - return withContext(Dispatchers.IO) { - val updateUrl = when (BuildType.CURRENT) { - BuildType.RELEASE -> Constants.UPDATE_URL_STABLE - - BuildType.NIGHTLY -> Constants.UPDATE_URL_NIGHTLY - - else -> { - Log.i(TAG, "Self-updates are not available for this build!") - return@withContext null - } - } - - try { - val response = httpClient.get(updateUrl, mapOf()) - val selfUpdate = json.decodeFromString(String(response.responseBytes)) - - val isUpdate = when (BuildType.CURRENT) { - BuildType.NIGHTLY, - BuildType.RELEASE -> selfUpdate.versionCode > BuildConfig.VERSION_CODE - - else -> false - } - - if (isUpdate) { - if (CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID)) { - if (selfUpdate.fdroidBuild.isNotEmpty()) { - return@withContext SelfUpdate.toApp(selfUpdate, context) - } - } else if (selfUpdate.auroraBuild.isNotEmpty()) { - return@withContext SelfUpdate.toApp(selfUpdate, context) - } else { - Log.e(TAG, "Update file is missing!") - return@withContext null - } - } - } catch (exception: Exception) { - Log.e(TAG, "Failed to check self-updates", exception) + private suspend fun getSelfUpdate(): App? = withContext(Dispatchers.IO) { + val updateUrl = when (BuildType.CURRENT) { + BuildType.RELEASE -> Constants.UPDATE_URL_VANILLA + BuildType.NIGHTLY -> Constants.UPDATE_URL_NIGHTLY + else -> { + Log.i(TAG, "Self-updates are not available for this build!") return@withContext null } - - Log.i(TAG, "No self-updates found!") - return@withContext null } + + try { + val selfUpdate = httpClient.call(updateUrl).use { + json.decodeFromString(it.body.string()) + } + + val isNewer = when (BuildType.CURRENT) { + BuildType.RELEASE -> selfUpdate.versionCode > BuildConfig.VERSION_CODE + BuildType.NIGHTLY -> selfUpdate.timestamp > BuildConfig.BUILD_TIMESTAMP + else -> false + } + + if (isNewer && selfUpdate.downloadUrl.isNotBlank()) { + return@withContext selfUpdate.toApp(context) + } + } catch (exception: Exception) { + Log.e(TAG, "Failed to check self-updates", exception) + } + + Log.i(TAG, "No self-updates found!") + return@withContext null } private fun notifyUpdates(updates: List) { with(context.getSystemService()!!) { notify( - notificationID, + NOTIFICATION_ID, NotificationUtil.getUpdateNotification(context, updates) ) } diff --git a/app/src/main/java/com/aurora/store/util/AppLockAuthenticator.kt b/app/src/main/java/com/aurora/store/util/AppLockAuthenticator.kt new file mode 100644 index 000000000..4901db64a --- /dev/null +++ b/app/src/main/java/com/aurora/store/util/AppLockAuthenticator.kt @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.util + +import android.app.KeyguardManager +import android.content.Context +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity + +/** + * Thin wrapper over [BiometricPrompt] that authenticates with the device biometric *or* the + * device credential (PIN / pattern / password). Devices without a fingerprint sensor — TVs, + * older phones — therefore fall back to the lockscreen credential automatically. + * + * Combining a biometric with [DEVICE_CREDENTIAL] is only supported by the prompt on API 30+; + * on older releases the deprecated `setDeviceCredentialAllowed` provides the same behaviour. + */ +object AppLockAuthenticator { + + private val supportsCombinedAuthenticators = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + + /** + * Whether the device can satisfy an app-lock challenge. True when a biometric or device + * credential is enrolled, or — for the pre-API-30 fallback path — the device simply has a + * secure lockscreen set. + */ + fun canAuthenticate(context: Context): Boolean { + val authenticators = if (supportsCombinedAuthenticators) { + BIOMETRIC_STRONG or DEVICE_CREDENTIAL + } else { + BIOMETRIC_STRONG + } + if (BiometricManager.from(context).canAuthenticate(authenticators) == + BiometricManager.BIOMETRIC_SUCCESS + ) { + return true + } + + // Pre-API-30 path: no enrolled biometric, but a PIN/pattern/password works as fallback + return context.getSystemService()?.isDeviceSecure == true + } + + /** + * Shows the authentication prompt. [onSuccess] fires on a successful unlock; [onError] + * fires on a non-recoverable error or user cancellation (the prompt itself handles + * transient failures like a wrong fingerprint). + */ + fun authenticate( + activity: FragmentActivity, + title: String, + subtitle: String, + onSuccess: () -> Unit, + onError: (CharSequence) -> Unit + ) { + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = + onSuccess() + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) = + onError(errString) + } + + val prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + callback + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .apply { + if (supportsCombinedAuthenticators) { + setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + } else { + @Suppress("DEPRECATION") + setDeviceCredentialAllowed(true) + } + } + .build() + + prompt.authenticate(promptInfo) + } +} diff --git a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt index 99a6b9634..49655fe1b 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -2,6 +2,7 @@ package com.aurora.store.util import android.app.Notification import android.app.NotificationChannel +import android.app.NotificationChannelGroup import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -22,58 +23,125 @@ import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.receiver.DownloadCancelReceiver +import com.aurora.store.data.receiver.DownloadRetryReceiver import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download as AuroraDownload import com.aurora.store.data.room.update.Update import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.math.absoluteValue object NotificationUtil { + // Channel groups: headings the individual channels are filed under in system settings. + private const val GROUP_CHANNELS_ACTIVITY = "com.aurora.store.channels.ACTIVITY" + private const val GROUP_CHANNELS_ALERTS = "com.aurora.store.channels.ALERTS" + + // Terminal install/failure notifications are bundled under a group so a bulk update + // shows a single collapsible summary instead of one notification per app. + private const val GROUP_INSTALLED = "com.aurora.store.INSTALLED" + private const val GROUP_FAILED = "com.aurora.store.FAILED" + + // Fixed IDs for the two group summaries. Kept well clear of the per-app IDs + // (packageName.hashCode()) and the worker IDs (100/200/500/501). + private const val SUMMARY_ID_INSTALLED = 1_000_001 + private const val SUMMARY_ID_FAILED = 1_000_002 + + // Successful installs are informational, so they expire on their own rather than + // lingering. Failures are actionable and persist until handled. + private val INSTALLED_TIMEOUT_MS = TimeUnit.HOURS.toMillis(6) + fun createNotificationChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationManager = context.getSystemService() + val notificationManager = context.getSystemService()!! + + // Drop channels retired or superseded by a lower-importance replacement, so the + // user no longer sees stale entries in system settings. + Constants.LEGACY_NOTIFICATION_CHANNELS.forEach { + notificationManager.deleteNotificationChannel(it) + } + + // Organise the channels under two headings in system settings: routine activity + // vs. things that need attention. + notificationManager.createNotificationChannelGroups( + listOf( + NotificationChannelGroup( + GROUP_CHANNELS_ACTIVITY, + context.getString(R.string.notification_group_activity) + ), + NotificationChannelGroup( + GROUP_CHANNELS_ALERTS, + context.getString(R.string.notification_group_alerts) + ) + ) + ) + val channels = ArrayList() - channels.add( - NotificationChannel( - Constants.NOTIFICATION_CHANNEL_INSTALL, - context.getString(R.string.notification_channel_install), - NotificationManager.IMPORTANCE_HIGH - ).apply { - setSound(null, null) - } - ) - channels.add( - NotificationChannel( - Constants.NOTIFICATION_CHANNEL_EXPORT, - context.getString(R.string.notification_channel_export), - NotificationManager.IMPORTANCE_HIGH - ) - ) - channels.add( - NotificationChannel( - Constants.NOTIFICATION_CHANNEL_ACCOUNT, - context.getString(R.string.notification_channel_account), - NotificationManager.IMPORTANCE_HIGH - ) - ) + + // Quiet, ongoing progress for active downloads. MIN keeps it collapsed and out + // of the status bar where possible. channels.add( NotificationChannel( Constants.NOTIFICATION_CHANNEL_DOWNLOADS, context.getString(R.string.notification_channel_downloads), NotificationManager.IMPORTANCE_MIN - ) + ).apply { + group = GROUP_CHANNELS_ACTIVITY + setSound(null, null) + setShowBadge(false) + } ) + + // Silent confirmation that an app finished installing/updating. LOW so it lands + // in the shade without buzzing for every app during a bulk update. + channels.add( + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_INSTALL, + context.getString(R.string.notification_channel_install), + NotificationManager.IMPORTANCE_LOW + ).apply { + group = GROUP_CHANNELS_ACTIVITY + setSound(null, null) + } + ) + + // New updates are available to install. DEFAULT but silent. channels.add( NotificationChannel( Constants.NOTIFICATION_CHANNEL_UPDATES, context.getString(R.string.notification_channel_updates), NotificationManager.IMPORTANCE_DEFAULT ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) - notificationManager!!.createNotificationChannels(channels) + + // Background app export. LOW since it's user-initiated and non-urgent. + channels.add( + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_EXPORT, + context.getString(R.string.notification_channel_export), + NotificationManager.IMPORTANCE_LOW + ).apply { + group = GROUP_CHANNELS_ACTIVITY + setSound(null, null) + } + ) + + // Things the user genuinely needs to act on: failed downloads/installs, pending + // user action and authentication prompts. HIGH so these aren't missed. + channels.add( + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_ALERTS, + context.getString(R.string.notification_channel_alerts), + NotificationManager.IMPORTANCE_HIGH + ).apply { + group = GROUP_CHANNELS_ALERTS + } + ) + + notificationManager.createNotificationChannels(channels) } } @@ -91,7 +159,14 @@ object NotificationUtil { largeIcon: Bitmap? = null, message: String? = null ): Notification { - val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_DOWNLOADS) + // Terminal failures go to the high-importance alerts channel so they aren't lost in + // the silent downloads channel; everything else stays quiet. + val channelId = if (download.status == DownloadStatus.FAILED) { + Constants.NOTIFICATION_CHANNEL_ALERTS + } else { + Constants.NOTIFICATION_CHANNEL_DOWNLOADS + } + val builder = NotificationCompat.Builder(context, channelId) builder.setSmallIcon(R.drawable.ic_notification_outlined) builder.setContentTitle(download.displayName) builder.setContentIntent(getContentIntentForDetails(context, download.packageName)) @@ -122,6 +197,9 @@ object NotificationUtil { builder.setContentText(message ?: context.getString(R.string.download_failed)) builder.color = Color.RED builder.setCategory(Notification.CATEGORY_ERROR) + builder.setGroup(GROUP_FAILED) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.addAction(getRetryAction(context, download.packageName)) } DownloadStatus.COMPLETED -> { @@ -175,6 +253,15 @@ object NotificationUtil { ) } + DownloadStatus.INSTALLING -> { + builder.setSmallIcon(android.R.drawable.stat_sys_download_done) + builder.setContentText(context.getString(R.string.status_installing)) + builder.setOngoing(true) + builder.setCategory(Notification.CATEGORY_PROGRESS) + builder.setProgress(100, 100, true) + builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + } + else -> {} } return builder.build() @@ -190,6 +277,11 @@ object NotificationUtil { .setContentTitle(displayName) .setContentText(context.getString(R.string.installer_status_success)) .setContentIntent(getContentIntentForDetails(context, packageName)) + .setCategory(Notification.CATEGORY_STATUS) + .setGroup(GROUP_INSTALLED) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setAutoCancel(true) + .setTimeoutAfter(INSTALLED_TIMEOUT_MS) .build() fun getInstallerStatusNotification( @@ -197,13 +289,158 @@ object NotificationUtil { packageName: String, displayName: String, content: String? - ): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_INSTALL) + ): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS) .setSmallIcon(R.drawable.ic_install) .setContentTitle(displayName) .setContentText(content) .setContentIntent(getContentIntentForDetails(context, packageName)) + .setCategory(Notification.CATEGORY_ERROR) + .setGroup(GROUP_FAILED) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .addAction(getRetryAction(context, packageName)) .build() + /** + * Posts the grouped "app installed" notification and refreshes the group summary so a + * bulk update collapses into a single "N apps installed" entry. + */ + fun notifyInstalled(context: Context, displayName: String, packageName: String) { + val notificationManager = context.getSystemService()!! + notificationManager.notify( + packageName.hashCode(), + getInstallNotification(context, displayName, packageName) + ) + refreshGroupSummaries(context) + } + + /** + * Posts the grouped install-failure notification (with a retry action) and refreshes the + * failure group summary. + */ + fun notifyInstallFailed( + context: Context, + packageName: String, + displayName: String, + content: String? + ) { + val notificationManager = context.getSystemService()!! + notificationManager.notify( + packageName.hashCode(), + getInstallerStatusNotification(context, packageName, displayName, content) + ) + refreshGroupSummaries(context) + } + + /** + * Cancels the per-app notification for [packageName] (e.g. once the user retries a failed + * install) and reconciles the group summaries. + */ + fun clearAppNotification(context: Context, packageName: String) { + context.getSystemService()!!.cancel(packageName.hashCode()) + refreshGroupSummaries(context) + } + + private fun getRetryAction(context: Context, packageName: String): NotificationCompat.Action { + val intent = Intent(context, DownloadRetryReceiver::class.java).apply { + putExtra(DownloadHelper.PACKAGE_NAME, packageName) + } + val pendingIntent = PendingIntentCompat.getBroadcast( + context, + packageName.hashCode().absoluteValue, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false + ) + return NotificationCompat.Action.Builder( + R.drawable.ic_updates, + context.getString(R.string.action_retry), + pendingIntent + ).build() + } + + /** + * Rebuilds the two group summaries from the currently-posted per-app notifications. Each + * summary lists its apps via [NotificationCompat.InboxStyle] and is removed once no + * children remain. Reading the live notifications (rather than tracking state ourselves) + * keeps the summaries correct across retries, dismissals and process restarts. + */ + fun refreshGroupSummaries(context: Context) { + // Grouping/summaries are only rendered from Android N onwards; on older versions a + // summary would just show as an extra standalone notification, so leave the children + // to display individually. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + + val notificationManager = context.getSystemService()!! + + refreshSummary( + notificationManager = notificationManager, + context = context, + group = GROUP_INSTALLED, + summaryId = SUMMARY_ID_INSTALLED, + channelId = Constants.NOTIFICATION_CHANNEL_INSTALL, + smallIcon = R.drawable.ic_install, + titleRes = R.plurals.notification_installed_summary, + timeoutMs = INSTALLED_TIMEOUT_MS, + contentIntent = getContentIntentForMain(context, initialTab = 2) + ) + refreshSummary( + notificationManager = notificationManager, + context = context, + group = GROUP_FAILED, + summaryId = SUMMARY_ID_FAILED, + channelId = Constants.NOTIFICATION_CHANNEL_ALERTS, + smallIcon = R.drawable.ic_download_fail, + titleRes = R.plurals.notification_failed_summary, + timeoutMs = null, + contentIntent = getContentIntentForMain(context, initialTab = 2) + ) + } + + @Suppress("LongParameterList") + private fun refreshSummary( + notificationManager: NotificationManager, + context: Context, + group: String, + summaryId: Int, + channelId: String, + smallIcon: Int, + titleRes: Int, + timeoutMs: Long?, + contentIntent: PendingIntent? + ) { + val children = notificationManager.activeNotifications.filter { + it.notification.group == group && it.id != summaryId + } + + if (children.isEmpty()) { + notificationManager.cancel(summaryId) + return + } + + val titles = children.mapNotNull { + it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + } + val title = context.resources.getQuantityString(titleRes, children.size, children.size) + + val inboxStyle = NotificationCompat.InboxStyle().setBigContentTitle(title) + titles.forEach { inboxStyle.addLine(it) } + + val summary = NotificationCompat.Builder(context, channelId) + .setSmallIcon(smallIcon) + .setContentTitle(title) + .setContentText(titles.joinToString(", ")) + .setStyle(inboxStyle) + .setGroup(group) + .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .apply { timeoutMs?.let { setTimeoutAfter(it) } } + .build() + + notificationManager.notify(summaryId, summary) + } + fun getUpdateNotification(context: Context): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES) .setSmallIcon(R.drawable.ic_updates) @@ -273,16 +510,23 @@ object NotificationUtil { .build() } - fun getExportNotification(context: Context): Notification = - NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT) - .setSmallIcon(R.drawable.ic_file_copy) - .setContentTitle(context.getString(R.string.export_app_title)) - .setContentText(context.getString(R.string.export_app_summary)) - .setOngoing(true) - .build() + fun getExportNotification( + context: Context, + displayName: String? = null, + progress: Int = -1 + ): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT) + .setSmallIcon(R.drawable.ic_file_copy) + .setContentTitle(displayName ?: context.getString(R.string.export_app_title)) + .setContentText(context.getString(R.string.export_app_summary)) + .setProgress(100, progress.coerceAtLeast(0), progress < 0) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_PROGRESS) + .setSilent(true) + .build() fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification = - NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ACCOUNT) + NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS) .setSmallIcon(R.drawable.ic_account) .setContentTitle(context.getString(R.string.authentication_required_title)) .setContentText(context.getString(R.string.authentication_required_unarchive)) diff --git a/app/src/main/java/com/aurora/store/util/PackageUtil.kt b/app/src/main/java/com/aurora/store/util/PackageUtil.kt index 75ad1b36c..19f7f7617 100644 --- a/app/src/main/java/com/aurora/store/util/PackageUtil.kt +++ b/app/src/main/java/com/aurora/store/util/PackageUtil.kt @@ -36,6 +36,8 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.pm.PackageInfoCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri +import com.aurora.Constants.FLAVOUR_PRELOAD +import com.aurora.Constants.FLAVOUR_VANILLA import com.aurora.Constants.PACKAGE_NAME_APP_GALLERY import com.aurora.Constants.PACKAGE_NAME_GMS import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE @@ -48,6 +50,7 @@ import com.aurora.extensions.isVAndAbove import com.aurora.extensions.isValidApp import com.aurora.store.BuildConfig import com.aurora.store.R +import com.aurora.store.data.model.BuildType import java.util.Locale object PackageUtil { @@ -184,6 +187,19 @@ object PackageUtil { } } + /** + * Build-level eligibility for Aurora Store's self-update: vanilla / preload flavors + * only, never debug, and never an F-Droid-signed build. Huawei is excluded by the + * flavor check. The user-facing toggle gates this further at runtime. + */ + fun isSelfUpdateSupported(context: Context): Boolean { + val flavorEligible = BuildConfig.FLAVOR == FLAVOUR_VANILLA || + BuildConfig.FLAVOR == FLAVOUR_PRELOAD + return flavorEligible && + BuildType.CURRENT != BuildType.DEBUG && + !CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID) + } + /** * Confirm if MicroG bundle is installed * Considering the following: diff --git a/app/src/main/java/com/aurora/store/util/PathUtil.kt b/app/src/main/java/com/aurora/store/util/PathUtil.kt index fe7108cc7..113a22bea 100644 --- a/app/src/main/java/com/aurora/store/util/PathUtil.kt +++ b/app/src/main/java/com/aurora/store/util/PathUtil.kt @@ -21,6 +21,7 @@ package com.aurora.store.util import android.content.Context import android.os.Environment +import androidx.core.content.pm.PackageInfoCompat import com.aurora.gplayapi.data.models.PlayFile import com.aurora.store.data.room.download.Download import java.io.File @@ -84,6 +85,69 @@ object PathUtil { } } + /** + * Resolves every on-disk file belonging to a finished [Download] (base/split APKs, + * shared-library APKs and OBB/patch files), mapped to the relative path it should + * occupy inside an exported zip bundle. + * + * APKs are taken from the download cache, falling back to the APKs of the installed + * app when the cache was cleared (e.g. auto-deleted after install). They keep their + * layout relative to the app's download directory (shared libraries stay under + * `libraries//`). OBB/patch files live outside the cache, survive the + * cleanup, and are placed under `Android/obb//` so they can be restored + * to their on-device location. + * + * Files that are missing on disk are skipped, as OBB/patch files are optional. + */ + fun getExportableFiles(context: Context, download: Download): Map { + val appDir = getAppDownloadDir(context, download.packageName, download.versionCode) + val playFiles = download.fileList + download.sharedLibs.flatMap { it.fileList } + val (obbPlayFiles, apkPlayFiles) = playFiles.partition { + it.type == PlayFile.Type.OBB || it.type == PlayFile.Type.PATCH + } + + val apkFiles = apkPlayFiles.associate { playFile -> + val file = getLocalFile(context, playFile, download) + file.relativeTo(appDir).invariantSeparatorsPath to file + }.filterValues { it.exists() } + .ifEmpty { getInstalledApkFiles(context, download.packageName, download.versionCode) } + + val obbFiles = obbPlayFiles.associate { playFile -> + val file = getLocalFile(context, playFile, download) + "Android/obb/${download.packageName}/${file.name}" to file + }.filterValues { it.exists() } + + return apkFiles + obbFiles + } + + /** + * Resolves the base and split APKs of the installed [packageName] from their on-device + * locations, used as a fallback when the downloaded files are no longer cached. Returns + * an empty map unless the installed version matches [versionCode], so a different + * installed version is never exported by mistake. + */ + private fun getInstalledApkFiles( + context: Context, + packageName: String, + versionCode: Long + ): Map { + val packageInfo = runCatching { + PackageUtil.getPackageInfo(context, packageName) + }.getOrNull() ?: return emptyMap() + + if (PackageInfoCompat.getLongVersionCode(packageInfo) != versionCode) return emptyMap() + + val appInfo = packageInfo.applicationInfo ?: return emptyMap() + val apkPaths = buildList { + appInfo.sourceDir?.let { add(it) } + appInfo.splitSourceDirs?.let { addAll(it.filterNotNull()) } + } + + return apkPaths.map { File(it) } + .filter { it.exists() } + .associateBy { it.name } + } + fun getZipFile(context: Context, packageName: String, versionCode: Long): File = File( getAppDownloadDir( context, diff --git a/app/src/main/java/com/aurora/store/util/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt index 5af10d467..e01de6560 100644 --- a/app/src/main/java/com/aurora/store/util/Preferences.kt +++ b/app/src/main/java/com/aurora/store/util/Preferences.kt @@ -43,6 +43,8 @@ object Preferences { const val PREFERENCE_AUTO_DELETE = "PREFERENCE_AUTO_DELETE" + const val PREFERENCE_NOTIFICATION_PROGRESS = "PREFERENCE_NOTIFICATION_PROGRESS" + const val PREFERENCE_INSTALLATION_DEVICE_OWNER = "PREFERENCE_INSTALLATION_DEVICE_OWNER" const val PREFERENCE_PROXY_URL = "PREFERENCE_PROXY_URL" @@ -74,6 +76,11 @@ object Preferences { const val PREFERENCE_MIGRATION_VERSION = "PREFERENCE_MIGRATION_VERSION" + const val PREFERENCE_SELF_UPDATE_ENABLED = "PREFERENCE_SELF_UPDATE_ENABLED" + + const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED" + const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT" + private var prefs: SharedPreferences? = null fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) { diff --git a/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt index e926b02f6..b567cdacd 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/auth/AuthViewModel.kt @@ -87,7 +87,12 @@ class AuthViewModel @Inject constructor( ) } catch (exception: Exception) { Log.e(TAG, "Failed to generate Session", exception) - _authState.value = AuthState.Failed(exception.message.toString()) + val message = when (exception) { + is UnknownHostException -> context.getString(R.string.check_connectivity) + else -> exception.message.toString() + } + + _authState.value = AuthState.Failed(message) } } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index b9b7aad0a..1ac90d4a3 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -314,7 +314,8 @@ class AppDetailsViewModel @Inject constructor( DownloadStatus.VERIFYING -> AppState.Verifying - DownloadStatus.COMPLETED -> if (isInstalled) defaultAppState else AppState.Installing(0F) + DownloadStatus.COMPLETED, + DownloadStatus.INSTALLING -> if (isInstalled) defaultAppState else AppState.Installing(0F) else -> defaultAppState } diff --git a/app/src/main/java/com/aurora/store/viewmodel/downloads/DownloadsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/downloads/DownloadsViewModel.kt index a5e8ca1ed..ff3855ead 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/downloads/DownloadsViewModel.kt @@ -75,7 +75,7 @@ class DownloadsViewModel @Inject constructor( fun install(download: Download) { try { - appInstaller.getPreferredInstaller().install(download) + appInstaller.getPreferredInstaller(notifyOnFallback = true).install(download) } catch (exception: Exception) { Log.e(TAG, "Failed to install ${download.packageName}", exception) } diff --git a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt index fd4eb2ea8..fca1bcec4 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt @@ -213,7 +213,9 @@ class MicroGViewModel @Inject constructor( download == null -> InstallStatus.PENDING download.status == DownloadStatus.FAILED -> InstallStatus.FAILED download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING - download.status == DownloadStatus.COMPLETED -> InstallStatus.INSTALLING + download.status == DownloadStatus.INSTALLED -> InstallStatus.INSTALLED + download.status == DownloadStatus.COMPLETED || + download.status == DownloadStatus.INSTALLING -> InstallStatus.INSTALLING else -> InstallStatus.DOWNLOADING } diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 000000000..9a0688bf9 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notification_settings.xml b/app/src/main/res/drawable/ic_notification_settings.xml new file mode 100644 index 000000000..426a472ab --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_settings.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c0f9cdae..87f9d60cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -149,6 +149,7 @@ %1$dm %2$ds left %1$ds left "Download failed" + Not enough storage space to download this app "Force clear all" "Getting metadata" "No downloads" @@ -177,6 +178,17 @@ Install App Manager or change the installer. No root access. Grant it or change the installer. Shizuku is not installed or set up properly. + Chosen installer is unavailable, using the default installer instead. + + + Security + App lock + Require biometric or screen-lock authentication to open Aurora Store. + Set up a screen lock (PIN, pattern, password or biometric) on your device first. + Authentication is required to access Aurora Store + Unlock + Unlock Aurora Store + Enter phone screen lock pattern, PIN, password or fingerprint "Aurora Services is available and ready to install." Install Aurora Services 1.0.9 or above, or change the installer. Set up Aurora Services and grant all permissions first. @@ -199,7 +211,9 @@ "Optionally you can choose Native installer, but then you can not install bundled (split) APKs, so choice is yours." "New update available" "You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root." - Account-related notification + Errors & alerts + Downloads & updates + Alerts App export notification Install notification Downloads notification @@ -228,6 +242,8 @@ "Similar and related apps" "Show updates that may fail" "Display updates for incompatible or disabled apps that may fail to install." + "Self-update" + "Offer new Aurora Store builds in the Updates tab." "Filter apps from other sources." "Do not check for updates for apps installed from sources outside Aurora Store" "Filter apps from other sources" @@ -284,6 +300,13 @@ "No network" "Purchase history" "Settings" + "Notifications" + Show download progress + Show detailed progress while downloading and updating apps. When off, a single quiet notification keeps downloads running in the background. + Categories + Tune sound and importance for each kind of notification + Notifications are turned off + Tap to enable notifications for Aurora Store "Spoof manager" "Search suggestion" "Search results" @@ -317,6 +340,8 @@ Enables background app download update available updates available + Self-update + A newer build of Aurora Store is available Incompatible updates These system app updates cannot be installed on this OS Updates requiring approval @@ -516,6 +541,8 @@ Queued Unavailable Verifying + Installing + Installed Authentication required @@ -558,6 +585,14 @@ Permission required Permissions required + + %d app installed + %d apps installed + + + %d app failed + %d apps failed + %1$d apps installed diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 010365dd1..e191bf108 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ agCoreservice = "13.3.1.300" androidGradlePlugin = "9.2.1" androidx-hilt = "1.3.0" androidx-junit = "1.3.0" +biometric = "1.1.0" browser = "1.10.0" coil = "3.4.0" composeBom = "2026.05.01" @@ -48,6 +49,7 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-adaptive-core = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" } androidx-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }