Merge branch 'improvement/notification-handling' into 'dev'
Improve how notifications are handled See merge request AuroraOSS/AuroraStore!574
This commit is contained in:
@@ -188,6 +188,11 @@
|
|||||||
android:name=".data.receiver.DownloadCancelReceiver"
|
android:name=".data.receiver.DownloadCancelReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- Retry Download Receiver-->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.receiver.DownloadRetryReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- SessionInstaller (as device owner) -->
|
<!-- SessionInstaller (as device owner) -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.receiver.DeviceOwnerReceiver"
|
android:name=".data.receiver.DeviceOwnerReceiver"
|
||||||
|
|||||||
@@ -42,11 +42,22 @@ object Constants {
|
|||||||
const val UPDATE_URL_NIGHTLY =
|
const val UPDATE_URL_NIGHTLY =
|
||||||
"https://auroraoss.com/downloads/AuroraStore/Feeds/nightly_feed.json"
|
"https://auroraoss.com/downloads/AuroraStore/Feeds/nightly_feed.json"
|
||||||
|
|
||||||
const val NOTIFICATION_CHANNEL_EXPORT = "NOTIFICATION_CHANNEL_EXPORT"
|
// Channel IDs carry a version suffix where the importance changed from a previous
|
||||||
const val NOTIFICATION_CHANNEL_INSTALL = "NOTIFICATION_CHANNEL_INSTALL"
|
// 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_DOWNLOADS = "NOTIFICATION_CHANNEL_DOWNLOADS"
|
||||||
const val NOTIFICATION_CHANNEL_UPDATES = "NOTIFICATION_CHANNEL_UPDATES"
|
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 GITLAB_URL = "https://gitlab.com/AuroraOSS/AuroraStore"
|
||||||
const val URL_DISPENSER = "https://auroraoss.com/api/auth"
|
const val URL_DISPENSER = "https://auroraoss.com/api/auth"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ sealed class Destination {
|
|||||||
data object NetworkPreference : Destination()
|
data object NetworkPreference : Destination()
|
||||||
data object Dispenser : Destination()
|
data object Dispenser : Destination()
|
||||||
data object UIPreference : Destination()
|
data object UIPreference : Destination()
|
||||||
|
data object NotificationPreference : Destination()
|
||||||
data object UpdatesPreference : Destination()
|
data object UpdatesPreference : Destination()
|
||||||
data object SourceFilters : Destination()
|
data object SourceFilters : Destination()
|
||||||
data object SecurityPreference : Destination()
|
data object SecurityPreference : Destination()
|
||||||
|
|||||||
@@ -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.installed.InstalledScreen
|
||||||
import com.aurora.store.compose.ui.main.MainScreen
|
import com.aurora.store.compose.ui.main.MainScreen
|
||||||
import com.aurora.store.compose.ui.onboarding.OnboardingScreen
|
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.SettingsScreen
|
||||||
import com.aurora.store.compose.ui.preferences.UIPreferenceScreen
|
import com.aurora.store.compose.ui.preferences.UIPreferenceScreen
|
||||||
import com.aurora.store.compose.ui.preferences.installation.InstallationPreferenceScreen
|
import com.aurora.store.compose.ui.preferences.installation.InstallationPreferenceScreen
|
||||||
@@ -161,6 +162,7 @@ fun NavDisplay(startDestination: NavKey) {
|
|||||||
Destination.NetworkPreference -> backstack.add(Screen.NetworkPreference)
|
Destination.NetworkPreference -> backstack.add(Screen.NetworkPreference)
|
||||||
Destination.Dispenser -> backstack.add(Screen.Dispenser)
|
Destination.Dispenser -> backstack.add(Screen.Dispenser)
|
||||||
Destination.UIPreference -> backstack.add(Screen.UIPreference)
|
Destination.UIPreference -> backstack.add(Screen.UIPreference)
|
||||||
|
Destination.NotificationPreference -> backstack.add(Screen.NotificationPreference)
|
||||||
Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference)
|
Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference)
|
||||||
Destination.SourceFilters -> backstack.add(Screen.SourceFilters)
|
Destination.SourceFilters -> backstack.add(Screen.SourceFilters)
|
||||||
Destination.SecurityPreference -> backstack.add(Screen.SecurityPreference)
|
Destination.SecurityPreference -> backstack.add(Screen.SecurityPreference)
|
||||||
@@ -284,6 +286,7 @@ fun NavDisplay(startDestination: NavKey) {
|
|||||||
entry<Screen.Settings> { SettingsScreen(onNavigateTo = ::navigate) }
|
entry<Screen.Settings> { SettingsScreen(onNavigateTo = ::navigate) }
|
||||||
entry<Screen.NetworkPreference> { NetworkPreferenceScreen(onNavigateTo = ::navigate) }
|
entry<Screen.NetworkPreference> { NetworkPreferenceScreen(onNavigateTo = ::navigate) }
|
||||||
entry<Screen.UIPreference> { UIPreferenceScreen() }
|
entry<Screen.UIPreference> { UIPreferenceScreen() }
|
||||||
|
entry<Screen.NotificationPreference> { NotificationPreferenceScreen() }
|
||||||
entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) }
|
entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) }
|
||||||
entry<Screen.SourceFilters> { SourceFiltersScreen() }
|
entry<Screen.SourceFilters> { SourceFiltersScreen() }
|
||||||
entry<Screen.SecurityPreference> { SecurityPreferenceScreen() }
|
entry<Screen.SecurityPreference> { SecurityPreferenceScreen() }
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ sealed class Screen : NavKey, Parcelable {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object UIPreference : Screen()
|
data object UIPreference : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object NotificationPreference : Screen()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object UpdatesPreference : Screen()
|
data object UpdatesPreference : Screen()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -88,6 +88,20 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) {
|
|||||||
headlineContent = { Text(stringResource(R.string.pref_ui_title)) }
|
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 {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },
|
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },
|
||||||
|
|||||||
@@ -190,6 +190,22 @@ class DownloadHelper @Inject constructor(
|
|||||||
downloadDao.insert(download)
|
downloadDao.insert(download)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-queues a previously failed (or otherwise inactive) download so it runs again. The
|
||||||
|
* worker resumes from any verified files on disk, so a retry after an install failure
|
||||||
|
* re-installs without re-downloading. No-op if the download is already active.
|
||||||
|
* @param packageName Name of the package to retry
|
||||||
|
*/
|
||||||
|
suspend fun retryDownload(packageName: String) {
|
||||||
|
val existing = getDownload(packageName) ?: return
|
||||||
|
if (existing.isActive) {
|
||||||
|
Log.i(TAG, "Skipping retry for $packageName; already ${existing.status}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Retrying download for $packageName")
|
||||||
|
downloadDao.updateStatus(packageName, DownloadStatus.QUEUED)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancels the download for the given package
|
* Cancels the download for the given package
|
||||||
* @param packageName Name of the package to cancel download
|
* @param packageName Name of the package to cancel download
|
||||||
|
|||||||
@@ -19,13 +19,11 @@
|
|||||||
|
|
||||||
package com.aurora.store.data.installer.base
|
package com.aurora.store.data.installer.base
|
||||||
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInstaller
|
import android.content.pm.PackageInstaller
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
import com.aurora.store.AuroraApp
|
import com.aurora.store.AuroraApp
|
||||||
import com.aurora.store.BuildConfig
|
import com.aurora.store.BuildConfig
|
||||||
@@ -42,13 +40,7 @@ abstract class InstallerBase(private val context: Context) : IInstaller {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun notifyInstallation(context: Context, displayName: String, packageName: String) {
|
fun notifyInstallation(context: Context, displayName: String, packageName: String) {
|
||||||
val notificationManager = context.getSystemService<NotificationManager>()
|
NotificationUtil.notifyInstalled(context, displayName, packageName)
|
||||||
val notification = NotificationUtil.getInstallNotification(
|
|
||||||
context,
|
|
||||||
displayName,
|
|
||||||
packageName
|
|
||||||
)
|
|
||||||
notificationManager!!.notify(packageName.hashCode(), notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getErrorString(context: Context, status: Int): String = when (status) {
|
fun getErrorString(context: Context, status: Int): String = when (status) {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.aurora.store.data.receiver
|
package com.aurora.store.data.receiver
|
||||||
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -26,7 +25,6 @@ import android.content.pm.PackageInstaller
|
|||||||
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
|
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
import com.aurora.extensions.runOnUiThread
|
import com.aurora.extensions.runOnUiThread
|
||||||
import com.aurora.store.AuroraApp
|
import com.aurora.store.AuroraApp
|
||||||
@@ -92,14 +90,12 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
status: Int
|
status: Int
|
||||||
) {
|
) {
|
||||||
val notificationManager = context.getSystemService<NotificationManager>()
|
NotificationUtil.notifyInstallFailed(
|
||||||
val notification = NotificationUtil.getInstallerStatusNotification(
|
|
||||||
context,
|
context,
|
||||||
packageName,
|
packageName,
|
||||||
displayName,
|
displayName,
|
||||||
InstallerBase.getErrorString(context, status)
|
InstallerBase.getErrorString(context, status)
|
||||||
)
|
)
|
||||||
notificationManager?.notify(packageName.hashCode(), notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun promptUser(context: Context, intent: Intent) {
|
internal fun promptUser(context: Context, intent: Intent) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ import com.aurora.store.util.CertUtil
|
|||||||
import com.aurora.store.util.NotificationUtil
|
import com.aurora.store.util.NotificationUtil
|
||||||
import com.aurora.store.util.PackageUtil
|
import com.aurora.store.util.PackageUtil
|
||||||
import com.aurora.store.util.PathUtil
|
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.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -93,6 +95,12 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
private val notificationManager = context.getSystemService<NotificationManager>()!!
|
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 icon: Bitmap? = null
|
||||||
private var totalBytes by Delegates.notNull<Long>()
|
private var totalBytes by Delegates.notNull<Long>()
|
||||||
private var totalProgress = 0
|
private var totalProgress = 0
|
||||||
@@ -252,6 +260,10 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private suspend fun onSuccess(): Result {
|
private suspend fun onSuccess(): Result {
|
||||||
return withContext(NonCancellable) {
|
return withContext(NonCancellable) {
|
||||||
return@withContext try {
|
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)
|
appInstaller.getPreferredInstaller(notifyOnFallback = true).install(download)
|
||||||
Result.success()
|
Result.success()
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
@@ -510,7 +522,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||||
val notification = if (this::download.isInitialized) {
|
val notification = if (this::download.isInitialized && showProgress) {
|
||||||
NotificationUtil.getDownloadNotification(context, download, icon)
|
NotificationUtil.getDownloadNotification(context, download, icon)
|
||||||
} else {
|
} else {
|
||||||
NotificationUtil.getDownloadNotification(context)
|
NotificationUtil.getDownloadNotification(context)
|
||||||
@@ -537,18 +549,44 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
downloadDao.updateStatus(download.packageName, status)
|
downloadDao.updateStatus(download.packageName, status)
|
||||||
|
|
||||||
when (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.VERIFYING,
|
||||||
DownloadStatus.CANCELLED -> return
|
DownloadStatus.CANCELLED -> {
|
||||||
|
notificationManager.cancel(download.packageName.hashCode())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
DownloadStatus.COMPLETED -> {
|
DownloadStatus.COMPLETED -> {
|
||||||
// Mark progress as 100 manually to avoid race conditions
|
// Mark progress as 100 manually to avoid race conditions
|
||||||
download.progress = 100
|
download.progress = 100
|
||||||
downloadDao.updateProgress(download.packageName, 100, 0, 0)
|
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 -> {}
|
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(
|
val notification = NotificationUtil.getDownloadNotification(
|
||||||
context,
|
context,
|
||||||
download,
|
download,
|
||||||
@@ -559,6 +597,12 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
|
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
|
||||||
notification
|
notification
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A failed download is a grouped child; reconcile the failure summary so a bulk
|
||||||
|
// update collapses into a single "N apps failed" entry.
|
||||||
|
if (status == DownloadStatus.FAILED) {
|
||||||
|
NotificationUtil.refreshGroupSummaries(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.aurora.store.util
|
|||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationChannelGroup
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
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.installer.AppInstaller
|
||||||
import com.aurora.store.data.model.DownloadStatus
|
import com.aurora.store.data.model.DownloadStatus
|
||||||
import com.aurora.store.data.receiver.DownloadCancelReceiver
|
import com.aurora.store.data.receiver.DownloadCancelReceiver
|
||||||
|
import com.aurora.store.data.receiver.DownloadRetryReceiver
|
||||||
import com.aurora.store.data.room.download.Download
|
import com.aurora.store.data.room.download.Download
|
||||||
import com.aurora.store.data.room.download.Download as AuroraDownload
|
import com.aurora.store.data.room.download.Download as AuroraDownload
|
||||||
import com.aurora.store.data.room.update.Update
|
import com.aurora.store.data.room.update.Update
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
object NotificationUtil {
|
object NotificationUtil {
|
||||||
|
|
||||||
|
// 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) {
|
fun createNotificationChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val notificationManager = context.getSystemService<NotificationManager>()
|
val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||||
|
|
||||||
|
// 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>()
|
val channels = ArrayList<NotificationChannel>()
|
||||||
channels.add(
|
|
||||||
NotificationChannel(
|
// Quiet, ongoing progress for active downloads. MIN keeps it collapsed and out
|
||||||
Constants.NOTIFICATION_CHANNEL_INSTALL,
|
// of the status bar where possible.
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
channels.add(
|
channels.add(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
Constants.NOTIFICATION_CHANNEL_DOWNLOADS,
|
Constants.NOTIFICATION_CHANNEL_DOWNLOADS,
|
||||||
context.getString(R.string.notification_channel_downloads),
|
context.getString(R.string.notification_channel_downloads),
|
||||||
NotificationManager.IMPORTANCE_MIN
|
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(
|
channels.add(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
Constants.NOTIFICATION_CHANNEL_UPDATES,
|
Constants.NOTIFICATION_CHANNEL_UPDATES,
|
||||||
context.getString(R.string.notification_channel_updates),
|
context.getString(R.string.notification_channel_updates),
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
).apply {
|
).apply {
|
||||||
|
group = GROUP_CHANNELS_ACTIVITY
|
||||||
setSound(null, null)
|
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,
|
largeIcon: Bitmap? = null,
|
||||||
message: String? = null
|
message: String? = null
|
||||||
): Notification {
|
): 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.setSmallIcon(R.drawable.ic_notification_outlined)
|
||||||
builder.setContentTitle(download.displayName)
|
builder.setContentTitle(download.displayName)
|
||||||
builder.setContentIntent(getContentIntentForDetails(context, download.packageName))
|
builder.setContentIntent(getContentIntentForDetails(context, download.packageName))
|
||||||
@@ -122,6 +197,9 @@ object NotificationUtil {
|
|||||||
builder.setContentText(message ?: context.getString(R.string.download_failed))
|
builder.setContentText(message ?: context.getString(R.string.download_failed))
|
||||||
builder.color = Color.RED
|
builder.color = Color.RED
|
||||||
builder.setCategory(Notification.CATEGORY_ERROR)
|
builder.setCategory(Notification.CATEGORY_ERROR)
|
||||||
|
builder.setGroup(GROUP_FAILED)
|
||||||
|
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||||
|
builder.addAction(getRetryAction(context, download.packageName))
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadStatus.COMPLETED -> {
|
DownloadStatus.COMPLETED -> {
|
||||||
@@ -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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
return builder.build()
|
return builder.build()
|
||||||
@@ -190,6 +277,11 @@ object NotificationUtil {
|
|||||||
.setContentTitle(displayName)
|
.setContentTitle(displayName)
|
||||||
.setContentText(context.getString(R.string.installer_status_success))
|
.setContentText(context.getString(R.string.installer_status_success))
|
||||||
.setContentIntent(getContentIntentForDetails(context, packageName))
|
.setContentIntent(getContentIntentForDetails(context, packageName))
|
||||||
|
.setCategory(Notification.CATEGORY_STATUS)
|
||||||
|
.setGroup(GROUP_INSTALLED)
|
||||||
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setTimeoutAfter(INSTALLED_TIMEOUT_MS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun getInstallerStatusNotification(
|
fun getInstallerStatusNotification(
|
||||||
@@ -197,13 +289,158 @@ object NotificationUtil {
|
|||||||
packageName: String,
|
packageName: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
content: String?
|
content: String?
|
||||||
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_INSTALL)
|
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS)
|
||||||
.setSmallIcon(R.drawable.ic_install)
|
.setSmallIcon(R.drawable.ic_install)
|
||||||
.setContentTitle(displayName)
|
.setContentTitle(displayName)
|
||||||
.setContentText(content)
|
.setContentText(content)
|
||||||
.setContentIntent(getContentIntentForDetails(context, packageName))
|
.setContentIntent(getContentIntentForDetails(context, packageName))
|
||||||
|
.setCategory(Notification.CATEGORY_ERROR)
|
||||||
|
.setGroup(GROUP_FAILED)
|
||||||
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||||
|
.addAction(getRetryAction(context, packageName))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the grouped "app installed" notification and refreshes the group summary so a
|
||||||
|
* bulk update collapses into a single "N apps installed" entry.
|
||||||
|
*/
|
||||||
|
fun notifyInstalled(context: Context, displayName: String, packageName: String) {
|
||||||
|
val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||||
|
notificationManager.notify(
|
||||||
|
packageName.hashCode(),
|
||||||
|
getInstallNotification(context, displayName, packageName)
|
||||||
|
)
|
||||||
|
refreshGroupSummaries(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the grouped install-failure notification (with a retry action) and refreshes the
|
||||||
|
* failure group summary.
|
||||||
|
*/
|
||||||
|
fun notifyInstallFailed(
|
||||||
|
context: Context,
|
||||||
|
packageName: String,
|
||||||
|
displayName: String,
|
||||||
|
content: String?
|
||||||
|
) {
|
||||||
|
val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||||
|
notificationManager.notify(
|
||||||
|
packageName.hashCode(),
|
||||||
|
getInstallerStatusNotification(context, packageName, displayName, content)
|
||||||
|
)
|
||||||
|
refreshGroupSummaries(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the per-app notification for [packageName] (e.g. once the user retries a failed
|
||||||
|
* install) and reconciles the group summaries.
|
||||||
|
*/
|
||||||
|
fun clearAppNotification(context: Context, packageName: String) {
|
||||||
|
context.getSystemService<NotificationManager>()!!.cancel(packageName.hashCode())
|
||||||
|
refreshGroupSummaries(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRetryAction(context: Context, packageName: String): NotificationCompat.Action {
|
||||||
|
val intent = Intent(context, DownloadRetryReceiver::class.java).apply {
|
||||||
|
putExtra(DownloadHelper.PACKAGE_NAME, packageName)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntentCompat.getBroadcast(
|
||||||
|
context,
|
||||||
|
packageName.hashCode().absoluteValue,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
return NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_updates,
|
||||||
|
context.getString(R.string.action_retry),
|
||||||
|
pendingIntent
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the two group summaries from the currently-posted per-app notifications. Each
|
||||||
|
* summary lists its apps via [NotificationCompat.InboxStyle] and is removed once no
|
||||||
|
* children remain. Reading the live notifications (rather than tracking state ourselves)
|
||||||
|
* keeps the summaries correct across retries, dismissals and process restarts.
|
||||||
|
*/
|
||||||
|
fun refreshGroupSummaries(context: Context) {
|
||||||
|
// Grouping/summaries are only rendered from Android N onwards; on older versions a
|
||||||
|
// summary would just show as an extra standalone notification, so leave the children
|
||||||
|
// to display individually.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||||
|
|
||||||
|
val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||||
|
|
||||||
|
refreshSummary(
|
||||||
|
notificationManager = notificationManager,
|
||||||
|
context = context,
|
||||||
|
group = GROUP_INSTALLED,
|
||||||
|
summaryId = SUMMARY_ID_INSTALLED,
|
||||||
|
channelId = Constants.NOTIFICATION_CHANNEL_INSTALL,
|
||||||
|
smallIcon = R.drawable.ic_install,
|
||||||
|
titleRes = R.plurals.notification_installed_summary,
|
||||||
|
timeoutMs = INSTALLED_TIMEOUT_MS,
|
||||||
|
contentIntent = getContentIntentForMain(context, initialTab = 2)
|
||||||
|
)
|
||||||
|
refreshSummary(
|
||||||
|
notificationManager = notificationManager,
|
||||||
|
context = context,
|
||||||
|
group = GROUP_FAILED,
|
||||||
|
summaryId = SUMMARY_ID_FAILED,
|
||||||
|
channelId = Constants.NOTIFICATION_CHANNEL_ALERTS,
|
||||||
|
smallIcon = R.drawable.ic_download_fail,
|
||||||
|
titleRes = R.plurals.notification_failed_summary,
|
||||||
|
timeoutMs = null,
|
||||||
|
contentIntent = getContentIntentForMain(context, initialTab = 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
private fun refreshSummary(
|
||||||
|
notificationManager: NotificationManager,
|
||||||
|
context: Context,
|
||||||
|
group: String,
|
||||||
|
summaryId: Int,
|
||||||
|
channelId: String,
|
||||||
|
smallIcon: Int,
|
||||||
|
titleRes: Int,
|
||||||
|
timeoutMs: Long?,
|
||||||
|
contentIntent: PendingIntent?
|
||||||
|
) {
|
||||||
|
val children = notificationManager.activeNotifications.filter {
|
||||||
|
it.notification.group == group && it.id != summaryId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.isEmpty()) {
|
||||||
|
notificationManager.cancel(summaryId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val titles = children.mapNotNull {
|
||||||
|
it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||||
|
}
|
||||||
|
val title = context.resources.getQuantityString(titleRes, children.size, children.size)
|
||||||
|
|
||||||
|
val inboxStyle = NotificationCompat.InboxStyle().setBigContentTitle(title)
|
||||||
|
titles.forEach { inboxStyle.addLine(it) }
|
||||||
|
|
||||||
|
val summary = NotificationCompat.Builder(context, channelId)
|
||||||
|
.setSmallIcon(smallIcon)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(titles.joinToString(", "))
|
||||||
|
.setStyle(inboxStyle)
|
||||||
|
.setGroup(group)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(contentIntent)
|
||||||
|
.apply { timeoutMs?.let { setTimeoutAfter(it) } }
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notificationManager.notify(summaryId, summary)
|
||||||
|
}
|
||||||
|
|
||||||
fun getUpdateNotification(context: Context): Notification =
|
fun getUpdateNotification(context: Context): Notification =
|
||||||
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
||||||
.setSmallIcon(R.drawable.ic_updates)
|
.setSmallIcon(R.drawable.ic_updates)
|
||||||
@@ -289,7 +526,7 @@ object NotificationUtil {
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification =
|
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)
|
.setSmallIcon(R.drawable.ic_account)
|
||||||
.setContentTitle(context.getString(R.string.authentication_required_title))
|
.setContentTitle(context.getString(R.string.authentication_required_title))
|
||||||
.setContentText(context.getString(R.string.authentication_required_unarchive))
|
.setContentText(context.getString(R.string.authentication_required_unarchive))
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ object Preferences {
|
|||||||
|
|
||||||
const val PREFERENCE_AUTO_DELETE = "PREFERENCE_AUTO_DELETE"
|
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_INSTALLATION_DEVICE_OWNER = "PREFERENCE_INSTALLATION_DEVICE_OWNER"
|
||||||
|
|
||||||
const val PREFERENCE_PROXY_URL = "PREFERENCE_PROXY_URL"
|
const val PREFERENCE_PROXY_URL = "PREFERENCE_PROXY_URL"
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ class AuthViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
Log.e(TAG, "Failed to generate Session", 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
app/src/main/res/drawable/ic_notification_settings.xml
Normal file
24
app/src/main/res/drawable/ic_notification_settings.xml
Normal 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>
|
||||||
@@ -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="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_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="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 & alerts</string>
|
||||||
|
<string name="notification_group_activity">Downloads & updates</string>
|
||||||
|
<string name="notification_group_alerts">Alerts</string>
|
||||||
<string name="notification_channel_export">App export notification</string>
|
<string name="notification_channel_export">App export notification</string>
|
||||||
<string name="notification_channel_install">Install notification</string>
|
<string name="notification_channel_install">Install notification</string>
|
||||||
<string name="notification_channel_downloads">Downloads 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_no_network">"No network"</string>
|
||||||
<string name="title_purchase_history">"Purchase history"</string>
|
<string name="title_purchase_history">"Purchase history"</string>
|
||||||
<string name="title_settings">"Settings"</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_spoof_manager">"Spoof manager"</string>
|
||||||
<string name="title_search_suggestion">"Search suggestion"</string>
|
<string name="title_search_suggestion">"Search suggestion"</string>
|
||||||
<string name="title_search_results">"Search results"</string>
|
<string name="title_search_results">"Search results"</string>
|
||||||
@@ -576,6 +585,14 @@
|
|||||||
<item quantity="one">Permission required</item>
|
<item quantity="one">Permission required</item>
|
||||||
<item quantity="other">Permissions required</item>
|
<item quantity="other">Permissions required</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="notification_installed_summary">
|
||||||
|
<item quantity="one">%d app installed</item>
|
||||||
|
<item quantity="other">%d apps installed</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="notification_failed_summary">
|
||||||
|
<item quantity="one">%d app failed</item>
|
||||||
|
<item quantity="other">%d apps failed</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- AppsGamesFragment -->
|
<!-- AppsGamesFragment -->
|
||||||
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>
|
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user