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" /> + + + 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..45bee12c4 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt @@ -88,6 +88,20 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) { headlineContent = { Text(stringResource(R.string.pref_ui_title)) } ) } + item { + ListItem( + modifier = Modifier.clickable { + onNavigateTo(Destination.NotificationPreference) + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_notification_settings), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.title_notifications)) } + ) + } item { ListItem( modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) }, 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 640aaf120..af3aaffeb 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -190,6 +190,22 @@ class DownloadHelper @Inject constructor( downloadDao.insert(download) } + /** + * Re-queues a previously failed (or otherwise inactive) download so it runs again. The + * worker resumes from any verified files on disk, so a retry after an install failure + * re-installs without re-downloading. No-op if the download is already active. + * @param packageName Name of the package to retry + */ + suspend fun retryDownload(packageName: String) { + val existing = getDownload(packageName) ?: return + if (existing.isActive) { + Log.i(TAG, "Skipping retry for $packageName; already ${existing.status}") + return + } + Log.i(TAG, "Retrying download for $packageName") + downloadDao.updateStatus(packageName, DownloadStatus.QUEUED) + } + /** * Cancels the download for the given package * @param packageName Name of the package to cancel download diff --git a/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt b/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt index 035523af3..43ad80794 100644 --- a/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt +++ b/app/src/main/java/com/aurora/store/data/installer/base/InstallerBase.kt @@ -19,13 +19,11 @@ package com.aurora.store.data.installer.base -import android.app.NotificationManager import android.content.Context import android.content.pm.PackageInstaller import android.net.Uri import android.util.Log import androidx.core.content.FileProvider -import androidx.core.content.getSystemService import com.aurora.extensions.TAG import com.aurora.store.AuroraApp import com.aurora.store.BuildConfig @@ -42,13 +40,7 @@ abstract class InstallerBase(private val context: Context) : IInstaller { companion object { fun notifyInstallation(context: Context, displayName: String, packageName: String) { - val notificationManager = context.getSystemService() - val notification = NotificationUtil.getInstallNotification( - context, - displayName, - packageName - ) - notificationManager!!.notify(packageName.hashCode(), notification) + NotificationUtil.notifyInstalled(context, displayName, packageName) } fun getErrorString(context: Context, status: Int): String = when (status) { diff --git a/app/src/main/java/com/aurora/store/data/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 260ae9de3..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 @@ -252,6 +260,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) { @@ -510,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) @@ -537,18 +549,44 @@ class DownloadWorker @AssistedInject constructor( downloadDao.updateStatus(download.packageName, status) when (status) { + // Internal phases the user doesn't need a separate notification for: the ongoing + // foreground progress notification already conveys that work is in progress. + // Clear any stale per-app notification (e.g. a prior failure being retried) so it + // doesn't linger. + DownloadStatus.PURCHASING, DownloadStatus.VERIFYING, - DownloadStatus.CANCELLED -> return + DownloadStatus.CANCELLED -> { + notificationManager.cancel(download.packageName.hashCode()) + return + } DownloadStatus.COMPLETED -> { // Mark progress as 100 manually to avoid race conditions download.progress = 100 downloadDao.updateProgress(download.packageName, 100, 0, 0) + + // Silently-installable apps install automatically and get a single + // "installed" notification afterwards, so a separate "download complete" + // notice is just noise. Only surface completion when the user must act on it + // (tap to install). + val needsUserAction = !AppInstaller.canInstallSilently( + context, + download.packageName, + download.targetSdk + ) + if (!needsUserAction) { + notificationManager.cancel(download.packageName.hashCode()) + return + } } else -> {} } + // Skip detailed progress refreshes when the user has hidden progress; the minimal + // foreground notification posted via getForegroundInfo keeps the download alive. + if (isProgress && !showProgress) return + val notification = NotificationUtil.getDownloadNotification( context, download, @@ -559,6 +597,12 @@ class DownloadWorker @AssistedInject constructor( if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(), notification ) + + // A failed download is a grouped child; reconcile the failure summary so a bulk + // update collapses into a single "N apps failed" entry. + if (status == DownloadStatus.FAILED) { + NotificationUtil.refreshGroupSummaries(context) + } } /** 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..49655fe1b 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -2,6 +2,7 @@ package com.aurora.store.util import android.app.Notification import android.app.NotificationChannel +import android.app.NotificationChannelGroup import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -22,58 +23,125 @@ import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.receiver.DownloadCancelReceiver +import com.aurora.store.data.receiver.DownloadRetryReceiver import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download as AuroraDownload import com.aurora.store.data.room.update.Update import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.math.absoluteValue object NotificationUtil { + // Channel groups: headings the individual channels are filed under in system settings. + private const val GROUP_CHANNELS_ACTIVITY = "com.aurora.store.channels.ACTIVITY" + private const val GROUP_CHANNELS_ALERTS = "com.aurora.store.channels.ALERTS" + + // Terminal install/failure notifications are bundled under a group so a bulk update + // shows a single collapsible summary instead of one notification per app. + private const val GROUP_INSTALLED = "com.aurora.store.INSTALLED" + private const val GROUP_FAILED = "com.aurora.store.FAILED" + + // Fixed IDs for the two group summaries. Kept well clear of the per-app IDs + // (packageName.hashCode()) and the worker IDs (100/200/500/501). + private const val SUMMARY_ID_INSTALLED = 1_000_001 + private const val SUMMARY_ID_FAILED = 1_000_002 + + // Successful installs are informational, so they expire on their own rather than + // lingering. Failures are actionable and persist until handled. + private val INSTALLED_TIMEOUT_MS = TimeUnit.HOURS.toMillis(6) + fun createNotificationChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationManager = context.getSystemService() + val notificationManager = context.getSystemService()!! + + // Drop channels retired or superseded by a lower-importance replacement, so the + // user no longer sees stale entries in system settings. + Constants.LEGACY_NOTIFICATION_CHANNELS.forEach { + notificationManager.deleteNotificationChannel(it) + } + + // Organise the channels under two headings in system settings: routine activity + // vs. things that need attention. + notificationManager.createNotificationChannelGroups( + listOf( + NotificationChannelGroup( + GROUP_CHANNELS_ACTIVITY, + context.getString(R.string.notification_group_activity) + ), + NotificationChannelGroup( + GROUP_CHANNELS_ALERTS, + context.getString(R.string.notification_group_alerts) + ) + ) + ) + val channels = ArrayList() - channels.add( - NotificationChannel( - Constants.NOTIFICATION_CHANNEL_INSTALL, - context.getString(R.string.notification_channel_install), - NotificationManager.IMPORTANCE_HIGH - ).apply { - setSound(null, null) - } - ) - channels.add( - NotificationChannel( - Constants.NOTIFICATION_CHANNEL_EXPORT, - context.getString(R.string.notification_channel_export), - NotificationManager.IMPORTANCE_HIGH - ) - ) - channels.add( - NotificationChannel( - Constants.NOTIFICATION_CHANNEL_ACCOUNT, - context.getString(R.string.notification_channel_account), - NotificationManager.IMPORTANCE_HIGH - ) - ) + + // Quiet, ongoing progress for active downloads. MIN keeps it collapsed and out + // of the status bar where possible. channels.add( NotificationChannel( Constants.NOTIFICATION_CHANNEL_DOWNLOADS, context.getString(R.string.notification_channel_downloads), NotificationManager.IMPORTANCE_MIN - ) + ).apply { + group = GROUP_CHANNELS_ACTIVITY + setSound(null, null) + setShowBadge(false) + } ) + + // Silent confirmation that an app finished installing/updating. LOW so it lands + // in the shade without buzzing for every app during a bulk update. + channels.add( + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_INSTALL, + context.getString(R.string.notification_channel_install), + NotificationManager.IMPORTANCE_LOW + ).apply { + group = GROUP_CHANNELS_ACTIVITY + setSound(null, null) + } + ) + + // New updates are available to install. DEFAULT but silent. channels.add( NotificationChannel( Constants.NOTIFICATION_CHANNEL_UPDATES, context.getString(R.string.notification_channel_updates), NotificationManager.IMPORTANCE_DEFAULT ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) - notificationManager!!.createNotificationChannels(channels) + + // Background app export. LOW since it's user-initiated and non-urgent. + channels.add( + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_EXPORT, + context.getString(R.string.notification_channel_export), + NotificationManager.IMPORTANCE_LOW + ).apply { + group = GROUP_CHANNELS_ACTIVITY + setSound(null, null) + } + ) + + // Things the user genuinely needs to act on: failed downloads/installs, pending + // user action and authentication prompts. HIGH so these aren't missed. + channels.add( + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_ALERTS, + context.getString(R.string.notification_channel_alerts), + NotificationManager.IMPORTANCE_HIGH + ).apply { + group = GROUP_CHANNELS_ALERTS + } + ) + + notificationManager.createNotificationChannels(channels) } } @@ -91,7 +159,14 @@ object NotificationUtil { largeIcon: Bitmap? = null, message: String? = null ): Notification { - val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_DOWNLOADS) + // Terminal failures go to the high-importance alerts channel so they aren't lost in + // the silent downloads channel; everything else stays quiet. + val channelId = if (download.status == DownloadStatus.FAILED) { + Constants.NOTIFICATION_CHANNEL_ALERTS + } else { + Constants.NOTIFICATION_CHANNEL_DOWNLOADS + } + val builder = NotificationCompat.Builder(context, channelId) builder.setSmallIcon(R.drawable.ic_notification_outlined) builder.setContentTitle(download.displayName) builder.setContentIntent(getContentIntentForDetails(context, download.packageName)) @@ -122,6 +197,9 @@ object NotificationUtil { builder.setContentText(message ?: context.getString(R.string.download_failed)) builder.color = Color.RED builder.setCategory(Notification.CATEGORY_ERROR) + builder.setGroup(GROUP_FAILED) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.addAction(getRetryAction(context, download.packageName)) } DownloadStatus.COMPLETED -> { @@ -175,6 +253,15 @@ object NotificationUtil { ) } + DownloadStatus.INSTALLING -> { + builder.setSmallIcon(android.R.drawable.stat_sys_download_done) + builder.setContentText(context.getString(R.string.status_installing)) + builder.setOngoing(true) + builder.setCategory(Notification.CATEGORY_PROGRESS) + builder.setProgress(100, 100, true) + builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + } + else -> {} } return builder.build() @@ -190,6 +277,11 @@ object NotificationUtil { .setContentTitle(displayName) .setContentText(context.getString(R.string.installer_status_success)) .setContentIntent(getContentIntentForDetails(context, packageName)) + .setCategory(Notification.CATEGORY_STATUS) + .setGroup(GROUP_INSTALLED) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setAutoCancel(true) + .setTimeoutAfter(INSTALLED_TIMEOUT_MS) .build() fun getInstallerStatusNotification( @@ -197,13 +289,158 @@ object NotificationUtil { packageName: String, displayName: String, content: String? - ): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_INSTALL) + ): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS) .setSmallIcon(R.drawable.ic_install) .setContentTitle(displayName) .setContentText(content) .setContentIntent(getContentIntentForDetails(context, packageName)) + .setCategory(Notification.CATEGORY_ERROR) + .setGroup(GROUP_FAILED) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .addAction(getRetryAction(context, packageName)) .build() + /** + * Posts the grouped "app installed" notification and refreshes the group summary so a + * bulk update collapses into a single "N apps installed" entry. + */ + fun notifyInstalled(context: Context, displayName: String, packageName: String) { + val notificationManager = context.getSystemService()!! + notificationManager.notify( + packageName.hashCode(), + getInstallNotification(context, displayName, packageName) + ) + refreshGroupSummaries(context) + } + + /** + * Posts the grouped install-failure notification (with a retry action) and refreshes the + * failure group summary. + */ + fun notifyInstallFailed( + context: Context, + packageName: String, + displayName: String, + content: String? + ) { + val notificationManager = context.getSystemService()!! + notificationManager.notify( + packageName.hashCode(), + getInstallerStatusNotification(context, packageName, displayName, content) + ) + refreshGroupSummaries(context) + } + + /** + * Cancels the per-app notification for [packageName] (e.g. once the user retries a failed + * install) and reconciles the group summaries. + */ + fun clearAppNotification(context: Context, packageName: String) { + context.getSystemService()!!.cancel(packageName.hashCode()) + refreshGroupSummaries(context) + } + + private fun getRetryAction(context: Context, packageName: String): NotificationCompat.Action { + val intent = Intent(context, DownloadRetryReceiver::class.java).apply { + putExtra(DownloadHelper.PACKAGE_NAME, packageName) + } + val pendingIntent = PendingIntentCompat.getBroadcast( + context, + packageName.hashCode().absoluteValue, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false + ) + return NotificationCompat.Action.Builder( + R.drawable.ic_updates, + context.getString(R.string.action_retry), + pendingIntent + ).build() + } + + /** + * Rebuilds the two group summaries from the currently-posted per-app notifications. Each + * summary lists its apps via [NotificationCompat.InboxStyle] and is removed once no + * children remain. Reading the live notifications (rather than tracking state ourselves) + * keeps the summaries correct across retries, dismissals and process restarts. + */ + fun refreshGroupSummaries(context: Context) { + // Grouping/summaries are only rendered from Android N onwards; on older versions a + // summary would just show as an extra standalone notification, so leave the children + // to display individually. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + + val notificationManager = context.getSystemService()!! + + refreshSummary( + notificationManager = notificationManager, + context = context, + group = GROUP_INSTALLED, + summaryId = SUMMARY_ID_INSTALLED, + channelId = Constants.NOTIFICATION_CHANNEL_INSTALL, + smallIcon = R.drawable.ic_install, + titleRes = R.plurals.notification_installed_summary, + timeoutMs = INSTALLED_TIMEOUT_MS, + contentIntent = getContentIntentForMain(context, initialTab = 2) + ) + refreshSummary( + notificationManager = notificationManager, + context = context, + group = GROUP_FAILED, + summaryId = SUMMARY_ID_FAILED, + channelId = Constants.NOTIFICATION_CHANNEL_ALERTS, + smallIcon = R.drawable.ic_download_fail, + titleRes = R.plurals.notification_failed_summary, + timeoutMs = null, + contentIntent = getContentIntentForMain(context, initialTab = 2) + ) + } + + @Suppress("LongParameterList") + private fun refreshSummary( + notificationManager: NotificationManager, + context: Context, + group: String, + summaryId: Int, + channelId: String, + smallIcon: Int, + titleRes: Int, + timeoutMs: Long?, + contentIntent: PendingIntent? + ) { + val children = notificationManager.activeNotifications.filter { + it.notification.group == group && it.id != summaryId + } + + if (children.isEmpty()) { + notificationManager.cancel(summaryId) + return + } + + val titles = children.mapNotNull { + it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + } + val title = context.resources.getQuantityString(titleRes, children.size, children.size) + + val inboxStyle = NotificationCompat.InboxStyle().setBigContentTitle(title) + titles.forEach { inboxStyle.addLine(it) } + + val summary = NotificationCompat.Builder(context, channelId) + .setSmallIcon(smallIcon) + .setContentTitle(title) + .setContentText(titles.joinToString(", ")) + .setStyle(inboxStyle) + .setGroup(group) + .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .apply { timeoutMs?.let { setTimeoutAfter(it) } } + .build() + + notificationManager.notify(summaryId, summary) + } + fun getUpdateNotification(context: Context): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES) .setSmallIcon(R.drawable.ic_updates) @@ -289,7 +526,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/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/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 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8712221e4..87f9d60cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -211,7 +211,9 @@ "Optionally you can choose Native installer, but then you can not install bundled (split) APKs, so choice is yours." "New update available" "You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root." - Account-related notification + Errors & alerts + Downloads & updates + Alerts App export notification Install notification Downloads notification @@ -298,6 +300,13 @@ "No network" "Purchase history" "Settings" + "Notifications" + Show download progress + Show detailed progress while downloading and updating apps. When off, a single quiet notification keeps downloads running in the background. + Categories + Tune sound and importance for each kind of notification + Notifications are turned off + Tap to enable notifications for Aurora Store "Spoof manager" "Search suggestion" "Search results" @@ -576,6 +585,14 @@ Permission required Permissions required + + %d app installed + %d apps installed + + + %d app failed + %d apps failed + %1$d apps installed