From aa5d3fcc4b987efa224e68196771bdce57ab114d Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:07:28 +0530 Subject: [PATCH] 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