Merge branch 'improvement/notification-handling' into 'dev'

Improve how notifications are handled

See merge request AuroraOSS/AuroraStore!574
This commit is contained in:
Rahul Patel
2026-05-30 19:17:23 +05:30
17 changed files with 638 additions and 50 deletions

View File

@@ -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"

View File

@@ -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"

View File

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

View File

@@ -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<Screen.Settings> { SettingsScreen(onNavigateTo = ::navigate) }
entry<Screen.NetworkPreference> { NetworkPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.UIPreference> { UIPreferenceScreen() }
entry<Screen.NotificationPreference> { NotificationPreferenceScreen() }
entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.SourceFilters> { SourceFiltersScreen() }
entry<Screen.SecurityPreference> { SecurityPreferenceScreen() }

View File

@@ -89,6 +89,9 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
data object UIPreference : Screen()
@Serializable
data object NotificationPreference : Screen()
@Serializable
data object UpdatesPreference : Screen()

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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<NotificationManager>()!!
// 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<Long>()
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)
}
}
/**

View File

@@ -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<NotificationManager>()
val notificationManager = context.getSystemService<NotificationManager>()!!
// 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<NotificationChannel>()
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>()!!
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)
@@ -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))

View File

@@ -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"

View File

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

View File

@@ -0,0 +1,24 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M480,471ZM480,880q-33,0 -56.5,-23.5T400,800h160q0,33 -23.5,56.5T480,880ZM160,760v-80h80v-280q0,-84 50.5,-149T422,167q-10,22 -15.5,46t-7.5,49q-35,21 -57,57t-22,81v280h320v-122q20,3 40,3t40,-3v122h80v80L160,760ZM640,480 L628,420q-12,-5 -22.5,-10.5T584,396l-58,18 -40,-68 46,-40q-2,-13 -2,-26t2,-26l-46,-40 40,-68 58,18q11,-8 21.5,-13.5T628,140l12,-60h80l12,60q12,5 22.5,10.5T776,164l58,-18 40,68 -46,40q2,13 2,26t-2,26l46,40 -40,68 -58,-18q-11,8 -21.5,13.5T732,420l-12,60h-80ZM736.5,336.5Q760,313 760,280t-23.5,-56.5Q713,200 680,200t-56.5,23.5Q600,247 600,280t23.5,56.5Q647,360 680,360t56.5,-23.5Z"
android:fillColor="#e3e3e3"/>
</vector>

View File

@@ -211,7 +211,9 @@
<string name="device_miui_extra">"Optionally you can choose Native installer, but then you can not install bundled (split) APKs, so choice is yours."</string>
<string name="dialog_title_self_update">"New update available"</string>
<string name="dialog_desc_native_split">"You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root."</string>
<string name="notification_channel_account">Account-related notification</string>
<string name="notification_channel_alerts">Errors &amp; alerts</string>
<string name="notification_group_activity">Downloads &amp; updates</string>
<string name="notification_group_alerts">Alerts</string>
<string name="notification_channel_export">App export notification</string>
<string name="notification_channel_install">Install notification</string>
<string name="notification_channel_downloads">Downloads notification</string>
@@ -298,6 +300,13 @@
<string name="title_no_network">"No network"</string>
<string name="title_purchase_history">"Purchase history"</string>
<string name="title_settings">"Settings"</string>
<string name="title_notifications">"Notifications"</string>
<string name="pref_notification_progress">Show download progress</string>
<string name="pref_notification_progress_desc">Show detailed progress while downloading and updating apps. When off, a single quiet notification keeps downloads running in the background.</string>
<string name="pref_notification_categories">Categories</string>
<string name="pref_notification_categories_desc">Tune sound and importance for each kind of notification</string>
<string name="pref_notification_disabled">Notifications are turned off</string>
<string name="pref_notification_disabled_desc">Tap to enable notifications for Aurora Store</string>
<string name="title_spoof_manager">"Spoof manager"</string>
<string name="title_search_suggestion">"Search suggestion"</string>
<string name="title_search_results">"Search results"</string>
@@ -576,6 +585,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>