From 012f4dcc26162d581987f18ac771f7e83bef21cb Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 17:54:59 +0530 Subject: [PATCH 1/6] Restructure notification channels by intent and importance Notifications were too intrusive: install-success and export sat on IMPORTANT_HIGH channels and produced heads-up alerts for every app, while genuine download failures were buried in the IMPORTANCE_MIN downloads channel where they were easily missed. Restructure the channels around user intent: - Download progress -> MIN, silent, no badge - Installed apps -> LOW, silent (was HIGH) - Updates available -> DEFAULT, silent - App export -> LOW, silent (was HIGH) - Errors & alerts -> HIGH (new, the only heads-up channel) Failed downloads, installer-status errors and the unarchive auth prompt now route to the high-importance alerts channel so they aren't lost, while successful installs/exports stay quiet. Android ignores importance edits on an already-created channel, so the install/export channels get new IDs and the retired IDs (including the folded-in account channel) are deleted on next launch. --- app/src/main/java/com/aurora/Constants.kt | 17 +++- .../com/aurora/store/util/NotificationUtil.kt | 92 +++++++++++++------ app/src/main/res/values/strings.xml | 2 +- 3 files changed, 78 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/aurora/Constants.kt b/app/src/main/java/com/aurora/Constants.kt index 29e9d0ce6..d42a65c56 100644 --- a/app/src/main/java/com/aurora/Constants.kt +++ b/app/src/main/java/com/aurora/Constants.kt @@ -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" 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..a75b276d0 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -32,38 +32,42 @@ object NotificationUtil { 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) + } + 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 { + 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 { + setSound(null, null) + } + ) + + // New updates are available to install. DEFAULT but silent. channels.add( NotificationChannel( Constants.NOTIFICATION_CHANNEL_UPDATES, @@ -73,7 +77,29 @@ object NotificationUtil { 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 { + 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 + ) + ) + + notificationManager.createNotificationChannels(channels) } } @@ -91,7 +117,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)) @@ -197,11 +230,12 @@ 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) .build() fun getUpdateNotification(context: Context): Notification = @@ -289,7 +323,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8712221e4..81707e8b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -211,7 +211,7 @@ "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 App export notification Install notification Downloads notification From 9bd5e3f8e7ea65cbaec141d452311e0f1536d3f9 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 17:57:50 +0530 Subject: [PATCH 2/6] Stop per-app download notification chatter during installs The download worker posted a separate per-app notification for every internal phase transition, keyed by package hash and distinct from the ongoing foreground progress notification. In practice this meant: - a blank, non-dismissable notification while "purchasing" (no branch rendered it), and - a transient "download complete" notice for every app that was then immediately replaced by the install-success notification. During a bulk update this produced a burst of redundant notifications. Suppress the purchasing/verifying phases (the ongoing progress notification already conveys activity) and only surface completion when the user must act on it (apps that can't be installed silently get a tap-to-install notice). Silently-installable apps now go straight to a single "installed" notification. Also clear any stale per-app notification on these phases and on cancellation, fixing a leak where a cancelled download left its last notification behind, and refresh the foreground notification to an "Installing" state so the user sees a clean downloading -> installing progression instead of a download bar stuck at 100%. --- .../aurora/store/data/work/DownloadWorker.kt | 28 ++++++++++++++++++- .../com/aurora/store/util/NotificationUtil.kt | 9 ++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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..3a393ff9f 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 @@ -252,6 +252,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) { @@ -537,13 +541,35 @@ 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 -> {} 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 a75b276d0..48085c60e 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -208,6 +208,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() From aa5d3fcc4b987efa224e68196771bdce57ab114d Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:07:28 +0530 Subject: [PATCH 3/6] 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 From a66098343800161f8806cacd2e7b83fe8378aa8b Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:17:52 +0530 Subject: [PATCH 4/6] Add a Notifications settings screen with a progress toggle There was no in-app control over notifications. Add a Notifications preference screen under Settings offering: - "Show download progress": when off, downloads/updates no longer post per-tick progress; a single quiet foreground notification keeps the work running in the background (honouring the mandatory FGS rule). - Deep links into each notification category's system settings (O+) so users can tune sound and importance per channel. - A shortcut to enable notifications when they're turned off at the system level, so the screen degrades gracefully. The download worker reads the progress preference live, keeping its foreground notification minimal and skipping progress refreshes when the user has opted out. POST_NOTIFICATIONS is already handled by the onboarding permission flow, so no duplicate prompt is added here. --- .../store/compose/navigation/Destination.kt | 1 + .../store/compose/navigation/NavDisplay.kt | 3 + .../aurora/store/compose/navigation/Screen.kt | 3 + .../NotificationPreferenceScreen.kt | 174 ++++++++++++++++++ .../compose/ui/preferences/SettingsScreen.kt | 14 ++ .../aurora/store/data/work/DownloadWorker.kt | 14 +- .../java/com/aurora/store/util/Preferences.kt | 2 + app/src/main/res/values/strings.xml | 7 + 8 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/aurora/store/compose/ui/preferences/NotificationPreferenceScreen.kt diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt index 7a3d48a53..4c81f2368 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt @@ -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() diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index b2223f827..857461033 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -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 { 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..03dae286c 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_outlined), + 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/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index b8b81e0a4..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 @@ -514,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) @@ -575,6 +583,10 @@ class DownloadWorker @AssistedInject constructor( 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, 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index de0922dfe..0c38aaf51 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -298,6 +298,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" From 60a6df58c5b760fa3d26410225d737463786d1a3 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:27:25 +0530 Subject: [PATCH 5/6] Organise notification channels into system-settings groups Group the five channels under two headings in the system notification settings so they're easier to navigate: "Downloads & updates" (progress, installed, updates, export) and "Alerts" (errors and action-required). Purely organisational; channel importances and behaviour are unchanged. --- .../com/aurora/store/util/NotificationUtil.kt | 28 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 29 insertions(+), 1 deletion(-) 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 9246712d0..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 @@ -32,6 +33,10 @@ 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" @@ -56,6 +61,21 @@ object NotificationUtil { 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() // Quiet, ongoing progress for active downloads. MIN keeps it collapsed and out @@ -66,6 +86,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_downloads), NotificationManager.IMPORTANCE_MIN ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) setShowBadge(false) } @@ -79,6 +100,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_install), NotificationManager.IMPORTANCE_LOW ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) @@ -90,6 +112,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_updates), NotificationManager.IMPORTANCE_DEFAULT ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) @@ -101,6 +124,7 @@ object NotificationUtil { context.getString(R.string.notification_channel_export), NotificationManager.IMPORTANCE_LOW ).apply { + group = GROUP_CHANNELS_ACTIVITY setSound(null, null) } ) @@ -112,7 +136,9 @@ object NotificationUtil { Constants.NOTIFICATION_CHANNEL_ALERTS, context.getString(R.string.notification_channel_alerts), NotificationManager.IMPORTANCE_HIGH - ) + ).apply { + group = GROUP_CHANNELS_ALERTS + } ) notificationManager.createNotificationChannels(channels) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c38aaf51..87f9d60cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -212,6 +212,8 @@ "New update available" "You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root." Errors & alerts + Downloads & updates + Alerts App export notification Install notification Downloads notification From 9f813d965b1612ee704b727e6d62e2b71172685c Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 18:41:31 +0530 Subject: [PATCH 6/6] fix linting issue & add new icon --- .../compose/ui/preferences/SettingsScreen.kt | 2 +- .../store/viewmodel/auth/AuthViewModel.kt | 7 +++++- .../res/drawable/ic_notification_settings.xml | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_notification_settings.xml 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 03dae286c..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 @@ -95,7 +95,7 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) { }, leadingContent = { Icon( - painter = painterResource(R.drawable.ic_notification_outlined), + painter = painterResource(R.drawable.ic_notification_settings), contentDescription = null ) }, 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 @@ + + + +