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.
This commit is contained in:
Rahul Patel
2026-05-30 18:17:52 +05:30
parent aa5d3fcc4b
commit a660983438
8 changed files with 217 additions and 1 deletions

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_outlined),
contentDescription = null
)
},
headlineContent = { Text(stringResource(R.string.title_notifications)) }
)
}
item {
ListItem(
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },

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

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"