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.
This commit is contained in:
Rahul Patel
2026-05-30 18:07:28 +05:30
parent 9bd5e3f8e7
commit aa5d3fcc4b
8 changed files with 249 additions and 14 deletions

View File

@@ -188,6 +188,11 @@
android:name=".data.receiver.DownloadCancelReceiver" android:name=".data.receiver.DownloadCancelReceiver"
android:exported="false" /> android:exported="false" />
<!-- Retry Download Receiver-->
<receiver
android:name=".data.receiver.DownloadRetryReceiver"
android:exported="false" />
<!-- SessionInstaller (as device owner) --> <!-- SessionInstaller (as device owner) -->
<receiver <receiver
android:name=".data.receiver.DeviceOwnerReceiver" android:name=".data.receiver.DeviceOwnerReceiver"

View File

@@ -190,6 +190,22 @@ class DownloadHelper @Inject constructor(
downloadDao.insert(download) 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 * Cancels the download for the given package
* @param packageName Name of the package to cancel download * @param packageName Name of the package to cancel download

View File

@@ -19,13 +19,11 @@
package com.aurora.store.data.installer.base package com.aurora.store.data.installer.base
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.BuildConfig import com.aurora.store.BuildConfig
@@ -42,13 +40,7 @@ abstract class InstallerBase(private val context: Context) : IInstaller {
companion object { companion object {
fun notifyInstallation(context: Context, displayName: String, packageName: String) { fun notifyInstallation(context: Context, displayName: String, packageName: String) {
val notificationManager = context.getSystemService<NotificationManager>() NotificationUtil.notifyInstalled(context, displayName, packageName)
val notification = NotificationUtil.getInstallNotification(
context,
displayName,
packageName
)
notificationManager!!.notify(packageName.hashCode(), notification)
} }
fun getErrorString(context: Context, status: Int): String = when (status) { fun getErrorString(context: Context, status: Int): String = when (status) {

View File

@@ -18,7 +18,6 @@
*/ */
package com.aurora.store.data.receiver package com.aurora.store.data.receiver
import android.app.NotificationManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -26,7 +25,6 @@ import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
import android.util.Log import android.util.Log
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.content.getSystemService
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.extensions.runOnUiThread import com.aurora.extensions.runOnUiThread
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
@@ -92,14 +90,12 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() {
displayName: String, displayName: String,
status: Int status: Int
) { ) {
val notificationManager = context.getSystemService<NotificationManager>() NotificationUtil.notifyInstallFailed(
val notification = NotificationUtil.getInstallerStatusNotification(
context, context,
packageName, packageName,
displayName, displayName,
InstallerBase.getErrorString(context, status) InstallerBase.getErrorString(context, status)
) )
notificationManager?.notify(packageName.hashCode(), notification)
} }
internal fun promptUser(context: Context, intent: Intent) { internal fun promptUser(context: Context, intent: Intent) {

View File

@@ -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)
}
}
}
}

View File

@@ -585,6 +585,12 @@ class DownloadWorker @AssistedInject constructor(
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(), if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
notification 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)
}
} }
/** /**

View File

@@ -22,14 +22,30 @@ import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.receiver.DownloadCancelReceiver 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
import com.aurora.store.data.room.download.Download as AuroraDownload import com.aurora.store.data.room.download.Download as AuroraDownload
import com.aurora.store.data.room.update.Update import com.aurora.store.data.room.update.Update
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
object NotificationUtil { 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) { fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService<NotificationManager>()!! val notificationManager = context.getSystemService<NotificationManager>()!!
@@ -155,6 +171,9 @@ object NotificationUtil {
builder.setContentText(message ?: context.getString(R.string.download_failed)) builder.setContentText(message ?: context.getString(R.string.download_failed))
builder.color = Color.RED builder.color = Color.RED
builder.setCategory(Notification.CATEGORY_ERROR) builder.setCategory(Notification.CATEGORY_ERROR)
builder.setGroup(GROUP_FAILED)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
builder.addAction(getRetryAction(context, download.packageName))
} }
DownloadStatus.COMPLETED -> { DownloadStatus.COMPLETED -> {
@@ -232,6 +251,11 @@ object NotificationUtil {
.setContentTitle(displayName) .setContentTitle(displayName)
.setContentText(context.getString(R.string.installer_status_success)) .setContentText(context.getString(R.string.installer_status_success))
.setContentIntent(getContentIntentForDetails(context, packageName)) .setContentIntent(getContentIntentForDetails(context, packageName))
.setCategory(Notification.CATEGORY_STATUS)
.setGroup(GROUP_INSTALLED)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setAutoCancel(true)
.setTimeoutAfter(INSTALLED_TIMEOUT_MS)
.build() .build()
fun getInstallerStatusNotification( fun getInstallerStatusNotification(
@@ -245,8 +269,152 @@ object NotificationUtil {
.setContentText(content) .setContentText(content)
.setContentIntent(getContentIntentForDetails(context, packageName)) .setContentIntent(getContentIntentForDetails(context, packageName))
.setCategory(Notification.CATEGORY_ERROR) .setCategory(Notification.CATEGORY_ERROR)
.setGroup(GROUP_FAILED)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.addAction(getRetryAction(context, packageName))
.build() .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>()!!
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>()!!
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<NotificationManager>()!!.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<NotificationManager>()!!
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 = fun getUpdateNotification(context: Context): Notification =
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES) NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_updates) .setSmallIcon(R.drawable.ic_updates)

View File

@@ -576,6 +576,14 @@
<item quantity="one">Permission required</item> <item quantity="one">Permission required</item>
<item quantity="other">Permissions required</item> <item quantity="other">Permissions required</item>
</plurals> </plurals>
<plurals name="notification_installed_summary">
<item quantity="one">%d app installed</item>
<item quantity="other">%d apps installed</item>
</plurals>
<plurals name="notification_failed_summary">
<item quantity="one">%d app failed</item>
<item quantity="other">%d apps failed</item>
</plurals>
<!-- AppsGamesFragment --> <!-- AppsGamesFragment -->
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string> <string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>