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:
@@ -188,6 +188,11 @@
|
||||
android:name=".data.receiver.DownloadCancelReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Retry Download Receiver-->
|
||||
<receiver
|
||||
android:name=".data.receiver.DownloadRetryReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- SessionInstaller (as device owner) -->
|
||||
<receiver
|
||||
android:name=".data.receiver.DeviceOwnerReceiver"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<NotificationManager>()
|
||||
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) {
|
||||
|
||||
@@ -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<NotificationManager>()
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<NotificationManager>()!!
|
||||
@@ -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>()!!
|
||||
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 =
|
||||
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
||||
.setSmallIcon(R.drawable.ic_updates)
|
||||
|
||||
@@ -576,6 +576,14 @@
|
||||
<item quantity="one">Permission required</item>
|
||||
<item quantity="other">Permissions required</item>
|
||||
</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 -->
|
||||
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>
|
||||
|
||||
Reference in New Issue
Block a user