From d13d50e442bb50328ec261e46dd4f42066301652 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Fri, 29 May 2026 23:28:09 +0530 Subject: [PATCH 01/15] Surface self-updates through the regular updates list Fold Aurora Store's self-update back into the existing update pipeline instead of a dedicated sheet/viewmodel/helper. The feed entry is mapped to a regular App and added to the update list, reusing the standard download + install path; it is never auto-installed silently. - Add a dedicated "Self-update" section in the Updates tab with the app-details navigation disabled (it's served from the Aurora OSS feed). - Gate eligibility via PackageUtil.isSelfUpdateSupported(): vanilla/preload flavors, not debug, not F-Droid (huawei excluded by flavor). - Add a build-aware Settings toggle as the opt-out, removing the row immediately when disabled. - Exempt nightly self-updates from deleteInvalidUpdates (static version code) and drop any stale self-update row when none is offered, so a phantom update doesn't linger after a self-install. --- app/build.gradle.kts | 5 + app/src/main/java/com/aurora/Constants.kt | 3 +- .../updates/UpdatesPreferenceScreen.kt | 32 +++++ .../store/compose/ui/updates/UpdatesScreen.kt | 29 +++- .../aurora/store/data/helper/UpdateHelper.kt | 17 +++ .../com/aurora/store/data/model/SelfUpdate.kt | 129 +++++++++--------- .../aurora/store/data/work/UpdateWorker.kt | 123 +++++++++-------- .../java/com/aurora/store/util/PackageUtil.kt | 16 +++ .../java/com/aurora/store/util/Preferences.kt | 2 + app/src/main/res/values/strings.xml | 4 + 10 files changed, 239 insertions(+), 121 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e29853e5..c4e52a7a3 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") } diff --git a/app/src/main/java/com/aurora/Constants.kt b/app/src/main/java/com/aurora/Constants.kt index 09ca67e34..29e9d0ce6 100644 --- a/app/src/main/java/com/aurora/Constants.kt +++ b/app/src/main/java/com/aurora/Constants.kt @@ -37,7 +37,8 @@ object Constants { const val SHARE_URL = "https://play.google.com/store/apps/details?id=" - const val UPDATE_URL_STABLE = "https://gitlab.com/AuroraOSS/AuroraStore/raw/master/updates.json" + const val UPDATE_URL_VANILLA = + "https://auroraoss.com/downloads/AuroraStore/Feeds/release_feed.json" const val UPDATE_URL_NIGHTLY = "https://auroraoss.com/downloads/AuroraStore/Feeds/nightly_feed.json" 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/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/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/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/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/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt index 5af10d467..58b2c13e8 100644 --- a/app/src/main/java/com/aurora/store/util/Preferences.kt +++ b/app/src/main/java/com/aurora/store/util/Preferences.kt @@ -74,6 +74,8 @@ object Preferences { const val PREFERENCE_MIGRATION_VERSION = "PREFERENCE_MIGRATION_VERSION" + const val PREFERENCE_SELF_UPDATE_ENABLED = "PREFERENCE_SELF_UPDATE_ENABLED" + private var prefs: SharedPreferences? = null fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c0f9cdae..a4e2f87ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -228,6 +228,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" @@ -317,6 +319,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 From ada5a1f899c9a1e699d4c41f6e67fb5b0a3f1b43 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:19:33 +0530 Subject: [PATCH 02/15] Harden download routine against redownloads and flaky networks - enqueue() no longer resets an active/verifying download back to QUEUED, so the periodic update check and repeated taps can't trigger needless re-downloads; when files are already downloaded & verified it installs directly instead of re-running the pipeline. - DownloadWorker only purges partial files on a genuine user cancellation; system-initiated stops (lost connectivity, quota) keep partials and retry, fixing downloads appearing to restart on a flaky network. - Add NetworkType.CONNECTED constraint + exponential backoff and return Result.retry() for transient/network failures (capped) instead of always succeeding. - Validate HTTP 206 before resuming a .tmp (overwrite on 200) and drop corrupt files on verification failure so retries restart clean. --- .../store/data/helper/DownloadHelper.kt | 61 +++++++++- .../store/data/room/download/Download.kt | 7 ++ .../aurora/store/data/work/DownloadWorker.kt | 110 +++++++++++++++--- 3 files changed, 156 insertions(+), 22 deletions(-) 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..be720efa1 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,19 @@ 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.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 +23,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 +38,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 { @@ -92,7 +99,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 +107,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 +115,40 @@ 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().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) } /** @@ -197,11 +237,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/room/download/Download.kt b/app/src/main/java/com/aurora/store/data/room/download/Download.kt index 5a2ef3f40..634cb5b6d 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, 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..4bc4135a6 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 @@ -48,6 +48,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.io.File import java.io.FileOutputStream +import java.net.HttpURLConnection.HTTP_PARTIAL import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException @@ -77,6 +78,10 @@ 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 @@ -184,17 +189,21 @@ 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 try { @@ -202,6 +211,9 @@ class DownloadWorker @AssistedInject constructor( files.forEach { file -> 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()) } @@ -223,12 +235,58 @@ 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 -> 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) { @@ -301,23 +359,39 @@ class DownloadWorker @AssistedInject constructor( } 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 { + val response = okHttpClient.call(gFile.url, headers) + if (!response.isSuccessful) { + response.close() + 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 -> onProgress(info) } } } From ab9b66eb94b296fcb9eb5a51f15061ef877a6d5f Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:27:51 +0530 Subject: [PATCH 03/15] Serialize downloads and make cache cleanup install-aware - CacheWorker no longer purges the files of a download that is still in-flight or downloaded and awaiting install, so missing the system install prompt for longer than the cache window no longer forces a re-download. - observeDownloads now starts the next queued download only once nothing else is purchasing/downloading/verifying, instead of merely checking for a DOWNLOADING row. This serializes downloads so concurrent workers can't clobber the shared foreground/progress notification. --- .../store/data/helper/DownloadHelper.kt | 6 +++++- .../aurora/store/data/model/DownloadStatus.kt | 8 ++++++++ .../com/aurora/store/data/work/CacheWorker.kt | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) 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 be720efa1..663d640e4 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 @@ -81,7 +81,11 @@ class DownloadHelper @Inject constructor( 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}") 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..36b34c089 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 @@ -16,5 +16,13 @@ enum class DownloadStatus(@StringRes val localized: Int) { companion object { val finished = listOf(FAILED, CANCELLED, COMPLETED) val running = listOf(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/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() } From 7e8746dbcbebebf1a09fd40c428a9c714207bcae Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:29:49 +0530 Subject: [PATCH 04/15] Abandon staged install session when a download is cancelled Cancelling a download cancelled the worker and marked the row CANCELLED but never abandoned a PackageInstaller session that had already been staged for install, leaking it. Add IInstaller.cancelInstall (no-op default, implemented by SessionInstaller) and call it from DownloadHelper.cancelDownload. Cross-process session persistence/reconciliation (committing a session staged before a restart) is left as a follow-up; the startup session cleanup remains, and is now cheap to recover from since CacheWorker keeps the downloaded files. --- .../com/aurora/store/data/helper/DownloadHelper.kt | 2 ++ .../aurora/store/data/installer/SessionInstaller.kt | 10 ++++++++++ .../com/aurora/store/data/installer/base/IInstaller.kt | 7 +++++++ 3 files changed, 19 insertions(+) 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 663d640e4..44f8ade6e 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 @@ -162,6 +162,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) } 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..413ca79e0 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, 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) {} } From 03638ca84ce3a07725b1a657412666bab46a337e Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:37:05 +0530 Subject: [PATCH 05/15] Track the install phase with INSTALLING/INSTALLED download states A download row previously stopped at COMPLETED and never reflected whether the app actually installed. Add INSTALLING/INSTALLED states driven by a central installer-event observer in DownloadHelper: - COMPLETED -> INSTALLING on the installer's first progress event - -> INSTALLED on success (row kept so the APK can still be exported) - a failed install reverts INSTALLING -> COMPLETED so it can be re-installed without re-downloading Consumers that branched on COMPLETED are updated (App Details state, MicroG status mapping, Downloads list icon). downloadStatus is stored as TEXT so no schema migration is needed. --- .../compose/composable/DownloadListItem.kt | 2 +- .../store/data/helper/DownloadHelper.kt | 34 +++++++++++++++++++ .../aurora/store/data/model/DownloadStatus.kt | 6 ++-- .../viewmodel/details/AppDetailsViewModel.kt | 3 +- .../viewmodel/onboarding/MicroGViewModel.kt | 4 ++- app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 46 insertions(+), 5 deletions(-) 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/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index 44f8ade6e..b948a4e25 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 @@ -14,6 +14,7 @@ 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 @@ -75,9 +76,42 @@ 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 { 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 36b34c089..e703d221c 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,12 @@ 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 finished = listOf(FAILED, CANCELLED, COMPLETED, INSTALLED) val running = listOf(QUEUED, PURCHASING, DOWNLOADING) /** 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/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/values/strings.xml b/app/src/main/res/values/strings.xml index a4e2f87ae..c95c36e05 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,6 +520,8 @@ Queued Unavailable Verifying + Installing + Installed Authentication required From 7d87a14c89a0a7c36b32dc691be28a9553da09fb Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:49:04 +0530 Subject: [PATCH 06/15] Harden download routine: storage checks, single verification, URL expiry - Check free space before downloading (only the not-yet-fetched bytes) and, on Android O+, use StorageManager.getAllocatableBytes/allocateBytes so the system can evict its cache; fail fast with a clear message instead of dying mid-write. SessionInstaller sets a SessionParams size hint so staging can reserve space too. - Verify each APK at most once: files already verified during the download pass are skipped in the final verification gate. Prefer SHA-256 and log SHA-1 fallback. - An expired download URL (403/410) now clears the stored file lists and retries, re-purchasing fresh URLs instead of repeatedly failing on the dead one. - Cancel promptly mid-file rather than only between files, and cancel copyTo's progress timer in a finally to avoid leaking the timer thread. - Download.canInstall now requires a real .apk on disk; DownloadStatus finished/running are Sets. --- .../java/com/aurora/extensions/InputStream.kt | 23 +++-- .../store/data/installer/SessionInstaller.kt | 10 +- .../aurora/store/data/model/DownloadStatus.kt | 4 +- .../store/data/room/download/Download.kt | 5 +- .../aurora/store/data/work/DownloadWorker.kt | 99 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 6 files changed, 124 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/aurora/extensions/InputStream.kt b/app/src/main/java/com/aurora/extensions/InputStream.kt index 4dc969a3d..7a508f2c3 100644 --- a/app/src/main/java/com/aurora/extensions/InputStream.kt +++ b/app/src/main/java/com/aurora/extensions/InputStream.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.flowOn fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow = 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/data/installer/SessionInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt index 413ca79e0..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 @@ -199,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) @@ -226,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/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt index e703d221c..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 @@ -16,8 +16,8 @@ enum class DownloadStatus(@StringRes val localized: Int) { INSTALLED(R.string.status_installed); companion object { - val finished = listOf(FAILED, CANCELLED, COMPLETED, INSTALLED) - 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 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 634cb5b6d..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 @@ -115,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/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index 4bc4135a6..9a972665c 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 @@ -48,6 +50,9 @@ 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 @@ -93,6 +98,10 @@ class DownloadWorker @AssistedInject constructor( 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)) @@ -102,6 +111,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() @@ -178,6 +192,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) { @@ -205,10 +228,13 @@ class DownloadWorker @AssistedInject constructor( // 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 @@ -259,7 +285,9 @@ class DownloadWorker @AssistedInject constructor( is NoNetworkException, is SocketException, is SocketTimeoutException, - is UnknownHostException -> true + is UnknownHostException, + // Expired URLs were re-purchased by clearing the file list; retrying re-fetches them. + is ExpiredUrlException -> true else -> isRetryable(throwable.cause) } @@ -355,6 +383,7 @@ 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 } @@ -372,7 +401,20 @@ class DownloadWorker @AssistedInject constructor( 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() } @@ -392,7 +434,12 @@ class DownloadWorker @AssistedInject constructor( response.body.byteStream().use { input -> FileOutputStream(tmpFile, resuming).use { - input.copyTo(it, gFile.size).collect { info -> onProgress(info) } + 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) + } } } @@ -526,6 +573,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) { @@ -563,4 +614,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index c95c36e05..7084817f4 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" From 25d0944e374dd50dde4c75d203996cc97f9504e2 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 05:39:18 +0530 Subject: [PATCH 07/15] Improve app export: completeness, reliability and progress Address several gaps in the app export feature (#1132): - Bundle shared-library APKs and OBB/patch files, preserving their relative layout (libraries// and Android/obb//). - Fall back to the installed app's on-device APKs when the cached download was cleared (e.g. auto-deleted after install), guarded by an exact version match so a different installed version is never exported. - Guard against the download row being replaced by a manual download of another version between enqueue and execution. - Fail (and delete the partial/empty document) instead of reporting success when there is nothing to bundle or an error occurs. - Store entries uncompressed and use larger buffers, so multi-GB exports are no longer bottlenecked on pointless re-compression. - Report progress through the foreground notification (alert once, then silent) and dedupe concurrent exports for the same app via unique work. --- .../aurora/store/data/work/ExportWorker.kt | 124 ++++++++++++++---- .../com/aurora/store/util/NotificationUtil.kt | 21 ++- .../java/com/aurora/store/util/PathUtil.kt | 64 +++++++++ 3 files changed, 177 insertions(+), 32 deletions(-) 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/util/NotificationUtil.kt b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt index 99a6b9634..36b6cea7a 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -273,13 +273,20 @@ 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) 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, From db9a7723a805d91a738cdd1723f1ec70c0413784 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 05:58:15 +0530 Subject: [PATCH 08/15] Fall back to session installer when chosen installer is unavailable Address #1386 The Shizuku branch of getPreferredInstaller() already fell back to the session installer, but the guard hasShizukuPerm() called Shizuku.checkSelfPermission(), which throws when the binder is not alive (Shizuku disabled). The exception escaped before the fallback could run, so installs failed instead of falling back. - Guard hasShizukuPerm() on Shizuku.pingBinder() and swallow failures so it returns false (instead of throwing) when Shizuku is unavailable, letting the existing fallback run. This also fixes canInstallSilently(). - Detect when any chosen installer resolves to the default and optionally inform the user via a toast, gated by notifyOnFallback so cancel/remove callers stay silent while real install paths surface it. --- .../store/data/helper/DownloadHelper.kt | 5 +- .../store/data/installer/AppInstaller.kt | 69 +++++++++++++------ .../aurora/store/data/work/DownloadWorker.kt | 2 +- .../viewmodel/downloads/DownloadsViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 5 files changed, 54 insertions(+), 25 deletions(-) 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 b948a4e25..640aaf120 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 @@ -174,8 +174,9 @@ class DownloadHelper @Inject constructor( if (existing != null && existing.versionCode == download.versionCode) { if (existing.canInstall(context)) { Log.i(TAG, "${download.packageName} already downloaded, installing directly") - runCatching { appInstaller.getPreferredInstaller().install(existing) } - .onFailure { Log.e(TAG, "Failed to install ${download.packageName}", it) } + runCatching { + appInstaller.getPreferredInstaller(notifyOnFallback = true).install(existing) + }.onFailure { Log.e(TAG, "Failed to install ${download.packageName}", it) } return } if (existing.isActive) { 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/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index 9a972665c..260ae9de3 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 @@ -252,7 +252,7 @@ class DownloadWorker @AssistedInject constructor( private suspend fun onSuccess(): Result { return withContext(NonCancellable) { return@withContext try { - appInstaller.getPreferredInstaller().install(download) + appInstaller.getPreferredInstaller(notifyOnFallback = true).install(download) Result.success() } catch (exception: Exception) { Log.e(TAG, "Failed to install ${download.packageName}", exception) 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7084817f4..8a0fc49e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -178,6 +178,7 @@ 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. "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. From a68d1b276dae262dbdbc513ff8ded0ad90c51187 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 15:25:27 +0530 Subject: [PATCH 09/15] Add optional app lock behind biometric or device credential Implements a full app lock (issue #1313) gating Aurora Store behind the device biometric or, where unavailable, the screen-lock credential (PIN/pattern/password) so it also works on TVs and older phones. - AppLockManager: process-scoped, in-memory lock state so a cold start is always locked, with a short background grace timeout to avoid re-prompting during the install dialog or the biometric sheet itself. - AppLockAuthenticator: BiometricPrompt wrapper using BIOMETRIC_STRONG | DEVICE_CREDENTIAL on API 30+ and setDeviceCredentialAllowed on older releases, plus an enrollment check. - ComposeActivity (now a FragmentActivity) gates content via a three-state machine (AUTHENTICATING/LOCKED/UNLOCKED); the authenticating state shows a blank surface behind the prompt so dismissing it never flashes the lock card. Re-locks on return past the timeout and sets FLAG_SECURE while locked. - Toggle lives on a dedicated Security preference screen, reached from Settings like the other preference entries. --- app/build.gradle.kts | 1 + .../java/com/aurora/store/ComposeActivity.kt | 105 ++++++++++++++++-- .../store/compose/navigation/Destination.kt | 1 + .../store/compose/navigation/NavDisplay.kt | 3 + .../aurora/store/compose/navigation/Screen.kt | 3 + .../store/compose/ui/lock/AppLockScreen.kt | 69 ++++++++++++ .../compose/ui/preferences/SettingsScreen.kt | 12 ++ .../security/SecurityPreferenceScreen.kt | 87 +++++++++++++++ .../com/aurora/store/data/AppLockManager.kt | 64 +++++++++++ .../aurora/store/util/AppLockAuthenticator.kt | 93 ++++++++++++++++ .../java/com/aurora/store/util/Preferences.kt | 3 + app/src/main/res/drawable/ic_lock.xml | 24 ++++ app/src/main/res/values/strings.xml | 10 ++ gradle/libs.versions.toml | 2 + 14 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/ui/lock/AppLockScreen.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/preferences/security/SecurityPreferenceScreen.kt create mode 100644 app/src/main/java/com/aurora/store/data/AppLockManager.kt create mode 100644 app/src/main/java/com/aurora/store/util/AppLockAuthenticator.kt create mode 100644 app/src/main/res/drawable/ic_lock.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4e52a7a3..96926eac4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -196,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/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/navigation/Destination.kt b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt index 8f8bc2b4f..7a3d48a53 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 @@ -46,4 +46,5 @@ sealed class Destination { data object UIPreference : 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..b2223f827 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 @@ -53,6 +53,7 @@ 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 @@ -162,6 +163,7 @@ fun NavDisplay(startDestination: NavKey) { Destination.UIPreference -> backstack.add(Screen.UIPreference) Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference) Destination.SourceFilters -> backstack.add(Screen.SourceFilters) + Destination.SecurityPreference -> backstack.add(Screen.SecurityPreference) } } @@ -284,6 +286,7 @@ fun NavDisplay(startDestination: NavKey) { entry { UIPreferenceScreen() } 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..87e14c11f 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 @@ -95,6 +95,9 @@ sealed class Screen : NavKey, Parcelable { @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/SettingsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt index a623e2a77..cde261b01 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 @@ -112,6 +112,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/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/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/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt index 58b2c13e8..9c7b621d0 100644 --- a/app/src/main/java/com/aurora/store/util/Preferences.kt +++ b/app/src/main/java/com/aurora/store/util/Preferences.kt @@ -76,6 +76,9 @@ object Preferences { 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/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/values/strings.xml b/app/src/main/res/values/strings.xml index 8a0fc49e5..8712221e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,16 @@ 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. 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" } From 012f4dcc26162d581987f18ac771f7e83bef21cb Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 17:54:59 +0530 Subject: [PATCH 10/15] Restructure notification channels by intent and importance Notifications were too intrusive: install-success and export sat on IMPORTANT_HIGH channels and produced heads-up alerts for every app, while genuine download failures were buried in the IMPORTANCE_MIN downloads channel where they were easily missed. Restructure the channels around user intent: - Download progress -> MIN, silent, no badge - Installed apps -> LOW, silent (was HIGH) - Updates available -> DEFAULT, silent - App export -> LOW, silent (was HIGH) - Errors & alerts -> HIGH (new, the only heads-up channel) Failed downloads, installer-status errors and the unarchive auth prompt now route to the high-importance alerts channel so they aren't lost, while successful installs/exports stay quiet. Android ignores importance edits on an already-created channel, so the install/export channels get new IDs and the retired IDs (including the folded-in account channel) are deleted on next launch. --- app/src/main/java/com/aurora/Constants.kt | 17 +++- .../com/aurora/store/util/NotificationUtil.kt | 92 +++++++++++++------ app/src/main/res/values/strings.xml | 2 +- 3 files changed, 78 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/aurora/Constants.kt b/app/src/main/java/com/aurora/Constants.kt index 29e9d0ce6..d42a65c56 100644 --- a/app/src/main/java/com/aurora/Constants.kt +++ b/app/src/main/java/com/aurora/Constants.kt @@ -42,11 +42,22 @@ object Constants { const val UPDATE_URL_NIGHTLY = "https://auroraoss.com/downloads/AuroraStore/Feeds/nightly_feed.json" - const val NOTIFICATION_CHANNEL_EXPORT = "NOTIFICATION_CHANNEL_EXPORT" - const val NOTIFICATION_CHANNEL_INSTALL = "NOTIFICATION_CHANNEL_INSTALL" + // Channel IDs carry a version suffix where the importance changed from a previous + // release: Android ignores importance edits on an already-created channel, so a new ID + // is the only way to roll out a lower importance. Retired IDs are listed in + // [LEGACY_NOTIFICATION_CHANNELS] so they can be deleted on next launch. + const val NOTIFICATION_CHANNEL_EXPORT = "NOTIFICATION_CHANNEL_EXPORT_V2" + const val NOTIFICATION_CHANNEL_INSTALL = "NOTIFICATION_CHANNEL_INSTALLED" const val NOTIFICATION_CHANNEL_DOWNLOADS = "NOTIFICATION_CHANNEL_DOWNLOADS" const val NOTIFICATION_CHANNEL_UPDATES = "NOTIFICATION_CHANNEL_UPDATES" - const val NOTIFICATION_CHANNEL_ACCOUNT = "NOTIFICATION_CHANNEL_ACCOUNT" + const val NOTIFICATION_CHANNEL_ALERTS = "NOTIFICATION_CHANNEL_ALERTS" + + // Channels removed or superseded by a higher-versioned ID; deleted on next launch. + val LEGACY_NOTIFICATION_CHANNELS = listOf( + "NOTIFICATION_CHANNEL_EXPORT", + "NOTIFICATION_CHANNEL_INSTALL", + "NOTIFICATION_CHANNEL_ACCOUNT" + ) const val GITLAB_URL = "https://gitlab.com/AuroraOSS/AuroraStore" const val URL_DISPENSER = "https://auroraoss.com/api/auth" 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 36b6cea7a..a75b276d0 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -32,38 +32,42 @@ object NotificationUtil { 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) + } + 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 { + 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 { + setSound(null, null) + } + ) + + // New updates are available to install. DEFAULT but silent. channels.add( NotificationChannel( Constants.NOTIFICATION_CHANNEL_UPDATES, @@ -73,7 +77,29 @@ object NotificationUtil { 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 { + 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 + ) + ) + + notificationManager.createNotificationChannels(channels) } } @@ -91,7 +117,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)) @@ -197,11 +230,12 @@ 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) .build() fun getUpdateNotification(context: Context): Notification = @@ -289,7 +323,7 @@ object NotificationUtil { .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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8712221e4..81707e8b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -211,7 +211,7 @@ "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 App export notification Install notification Downloads notification From 9bd5e3f8e7ea65cbaec141d452311e0f1536d3f9 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 17:57:50 +0530 Subject: [PATCH 11/15] Stop per-app download notification chatter during installs The download worker posted a separate per-app notification for every internal phase transition, keyed by package hash and distinct from the ongoing foreground progress notification. In practice this meant: - a blank, non-dismissable notification while "purchasing" (no branch rendered it), and - a transient "download complete" notice for every app that was then immediately replaced by the install-success notification. During a bulk update this produced a burst of redundant notifications. Suppress the purchasing/verifying phases (the ongoing progress notification already conveys activity) and only surface completion when the user must act on it (apps that can't be installed silently get a tap-to-install notice). Silently-installable apps now go straight to a single "installed" notification. Also clear any stale per-app notification on these phases and on cancellation, fixing a leak where a cancelled download left its last notification behind, and refresh the foreground notification to an "Installing" state so the user sees a clean downloading -> installing progression instead of a download bar stuck at 100%. --- .../aurora/store/data/work/DownloadWorker.kt | 28 ++++++++++++++++++- .../com/aurora/store/util/NotificationUtil.kt | 9 ++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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 260ae9de3..3a393ff9f 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 @@ -252,6 +252,10 @@ class DownloadWorker @AssistedInject constructor( private suspend fun onSuccess(): Result { return withContext(NonCancellable) { return@withContext try { + // 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) { @@ -537,13 +541,35 @@ 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 -> {} 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 a75b276d0..48085c60e 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -208,6 +208,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() From aa5d3fcc4b987efa224e68196771bdce57ab114d Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:07:28 +0530 Subject: [PATCH 12/15] Group install and failure notifications under summaries A bulk update produced one standalone notification per app for both successes and failures, flooding the shade. Bundle these terminal notifications into two groups, each with a collapsible summary that lists the apps via InboxStyle: - "N apps installed" on the (LOW, silent) installed channel, expiring on its own since it's informational. - "N apps failed" on the (HIGH) alerts channel, persisting until handled and carrying a Retry action per app. Summaries are rebuilt from the live posted notifications rather than tracked state, so they stay correct across retries, dismissals and process restarts, and are only posted on Android N+ where the system actually renders groups. Retry re-queues the download via a new DownloadRetryReceiver; the worker resumes from verified files on disk, so retrying an install failure re-installs without re-downloading. --- app/src/main/AndroidManifest.xml | 5 + .../store/data/helper/DownloadHelper.kt | 16 ++ .../data/installer/base/InstallerBase.kt | 10 +- .../receiver/BaseInstallerStatusReceiver.kt | 6 +- .../data/receiver/DownloadRetryReceiver.kt | 44 +++++ .../aurora/store/data/work/DownloadWorker.kt | 6 + .../com/aurora/store/util/NotificationUtil.kt | 168 ++++++++++++++++++ app/src/main/res/values/strings.xml | 8 + 8 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/data/receiver/DownloadRetryReceiver.kt 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" /> + + + () - 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/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/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index 3a393ff9f..b8b81e0a4 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 @@ -585,6 +585,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) + } } /** 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 48085c60e..9246712d0 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -22,14 +22,30 @@ 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 { + // 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()!! @@ -155,6 +171,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 -> { @@ -232,6 +251,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( @@ -245,8 +269,152 @@ object NotificationUtil { .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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81707e8b8..de0922dfe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -576,6 +576,14 @@ Permission required Permissions required + + %d app installed + %d apps installed + + + %d app failed + %d apps failed + %1$d apps installed From a66098343800161f8806cacd2e7b83fe8378aa8b Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:17:52 +0530 Subject: [PATCH 13/15] Add a Notifications settings screen with a progress toggle There was no in-app control over notifications. Add a Notifications preference screen under Settings offering: - "Show download progress": when off, downloads/updates no longer post per-tick progress; a single quiet foreground notification keeps the work running in the background (honouring the mandatory FGS rule). - Deep links into each notification category's system settings (O+) so users can tune sound and importance per channel. - A shortcut to enable notifications when they're turned off at the system level, so the screen degrades gracefully. The download worker reads the progress preference live, keeping its foreground notification minimal and skipping progress refreshes when the user has opted out. POST_NOTIFICATIONS is already handled by the onboarding permission flow, so no duplicate prompt is added here. --- .../store/compose/navigation/Destination.kt | 1 + .../store/compose/navigation/NavDisplay.kt | 3 + .../aurora/store/compose/navigation/Screen.kt | 3 + .../NotificationPreferenceScreen.kt | 174 ++++++++++++++++++ .../compose/ui/preferences/SettingsScreen.kt | 14 ++ .../aurora/store/data/work/DownloadWorker.kt | 14 +- .../java/com/aurora/store/util/Preferences.kt | 2 + app/src/main/res/values/strings.xml | 7 + 8 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/aurora/store/compose/ui/preferences/NotificationPreferenceScreen.kt 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 7a3d48a53..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,7 @@ 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 b2223f827..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,6 +48,7 @@ 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 @@ -161,6 +162,7 @@ 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) @@ -284,6 +286,7 @@ 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 87e14c11f..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,6 +89,9 @@ sealed class Screen : NavKey, Parcelable { @Serializable data object UIPreference : Screen() + @Serializable + data object NotificationPreference : Screen() + @Serializable data object UpdatesPreference : Screen() 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 cde261b01..03dae286c 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_outlined), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.title_notifications)) } + ) + } item { ListItem( modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) }, 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 b8b81e0a4..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 @@ -46,6 +46,8 @@ 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 @@ -93,6 +95,12 @@ class DownloadWorker @AssistedInject constructor( 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 @@ -514,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) @@ -575,6 +583,10 @@ class DownloadWorker @AssistedInject constructor( 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, 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 9c7b621d0..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" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de0922dfe..0c38aaf51 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -298,6 +298,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" From 60a6df58c5b760fa3d26410225d737463786d1a3 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:27:25 +0530 Subject: [PATCH 14/15] Organise notification channels into system-settings groups Group the five channels under two headings in the system notification settings so they're easier to navigate: "Downloads & updates" (progress, installed, updates, export) and "Alerts" (errors and action-required). Purely organisational; channel importances and behaviour are unchanged. --- .../com/aurora/store/util/NotificationUtil.kt | 28 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 29 insertions(+), 1 deletion(-) 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 9246712d0..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 @@ -32,6 +33,10 @@ 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" @@ -56,6 +61,21 @@ object NotificationUtil { 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() // Quiet, ongoing progress for active downloads. MIN keeps it collapsed and out @@ -66,6 +86,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_downloads), NotificationManager.IMPORTANCE_MIN ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) setShowBadge(false) } @@ -79,6 +100,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_install), NotificationManager.IMPORTANCE_LOW ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) @@ -90,6 +112,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_updates), NotificationManager.IMPORTANCE_DEFAULT ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) @@ -101,6 +124,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_export), NotificationManager.IMPORTANCE_LOW ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) @@ -112,7 +136,9 @@ object NotificationUtil { Constants.NOTIFICATION_CHANNEL_ALERTS, context.getString(R.string.notification_channel_alerts), NotificationManager.IMPORTANCE_HIGH - ) + ).apply { + group = GROUP_CHANNELS_ALERTS + } ) notificationManager.createNotificationChannels(channels) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c38aaf51..87f9d60cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -212,6 +212,8 @@ "New update available" "You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root." Errors & alerts + Downloads & updates + Alerts App export notification Install notification Downloads notification From 9f813d965b1612ee704b727e6d62e2b71172685c Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:41:31 +0530 Subject: [PATCH 15/15] fix linting issue & add new icon --- .../compose/ui/preferences/SettingsScreen.kt | 2 +- .../store/viewmodel/auth/AuthViewModel.kt | 7 +++++- .../res/drawable/ic_notification_settings.xml | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_notification_settings.xml 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 03dae286c..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 @@ -95,7 +95,7 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) { }, leadingContent = { Icon( - painter = painterResource(R.drawable.ic_notification_outlined), + painter = painterResource(R.drawable.ic_notification_settings), contentDescription = null ) }, 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/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 @@ + + + +