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:exported="false" />
|
||||
|
||||
<!-- Retry Download Receiver-->
|
||||
<receiver
|
||||
android:name=".data.receiver.DownloadRetryReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- SessionInstaller (as device owner) -->
|
||||
<receiver
|
||||
android:name=".data.receiver.DeviceOwnerReceiver"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -89,6 +89,9 @@ sealed class Screen : NavKey, Parcelable {
|
||||
@Serializable
|
||||
data object UIPreference : Screen()
|
||||
|
||||
@Serializable
|
||||
data object NotificationPreference : Screen()
|
||||
|
||||
@Serializable
|
||||
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)) }
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
onNavigateTo(Destination.NotificationPreference)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_notification_settings),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(stringResource(R.string.title_notifications)) }
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },
|
||||
|
||||
@@ -190,6 +190,22 @@ class DownloadHelper @Inject constructor(
|
||||
downloadDao.insert(download)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-queues a previously failed (or otherwise inactive) download so it runs again. The
|
||||
* worker resumes from any verified files on disk, so a retry after an install failure
|
||||
* re-installs without re-downloading. No-op if the download is already active.
|
||||
* @param packageName Name of the package to retry
|
||||
*/
|
||||
suspend fun retryDownload(packageName: String) {
|
||||
val existing = getDownload(packageName) ?: return
|
||||
if (existing.isActive) {
|
||||
Log.i(TAG, "Skipping retry for $packageName; already ${existing.status}")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Retrying download for $packageName")
|
||||
downloadDao.updateStatus(packageName, DownloadStatus.QUEUED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the download for the given package
|
||||
* @param packageName Name of the package to cancel download
|
||||
|
||||
@@ -19,13 +19,11 @@
|
||||
|
||||
package com.aurora.store.data.installer.base
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.getSystemService
|
||||
import com.aurora.extensions.TAG
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.BuildConfig
|
||||
@@ -42,13 +40,7 @@ abstract class InstallerBase(private val context: Context) : IInstaller {
|
||||
|
||||
companion object {
|
||||
fun notifyInstallation(context: Context, displayName: String, packageName: String) {
|
||||
val notificationManager = context.getSystemService<NotificationManager>()
|
||||
val notification = NotificationUtil.getInstallNotification(
|
||||
context,
|
||||
displayName,
|
||||
packageName
|
||||
)
|
||||
notificationManager!!.notify(packageName.hashCode(), notification)
|
||||
NotificationUtil.notifyInstalled(context, displayName, packageName)
|
||||
}
|
||||
|
||||
fun getErrorString(context: Context, status: Int): String = when (status) {
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
package com.aurora.store.data.receiver
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -26,7 +25,6 @@ import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
|
||||
import android.util.Log
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import com.aurora.extensions.TAG
|
||||
import com.aurora.extensions.runOnUiThread
|
||||
import com.aurora.store.AuroraApp
|
||||
@@ -92,14 +90,12 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() {
|
||||
displayName: String,
|
||||
status: Int
|
||||
) {
|
||||
val notificationManager = context.getSystemService<NotificationManager>()
|
||||
val notification = NotificationUtil.getInstallerStatusNotification(
|
||||
NotificationUtil.notifyInstallFailed(
|
||||
context,
|
||||
packageName,
|
||||
displayName,
|
||||
InstallerBase.getErrorString(context, status)
|
||||
)
|
||||
notificationManager?.notify(packageName.hashCode(), notification)
|
||||
}
|
||||
|
||||
internal fun promptUser(context: Context, intent: Intent) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.aurora.extensions.TAG
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.data.helper.DownloadHelper
|
||||
import com.aurora.store.util.NotificationUtil
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Re-queues a previously failed download/install when the user taps "Retry" on its
|
||||
* (grouped) failure notification.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class DownloadRetryReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var downloadHelper: DownloadHelper
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val packageName = intent?.getStringExtra(DownloadHelper.PACKAGE_NAME) ?: ""
|
||||
|
||||
if (packageName.isNotBlank()) {
|
||||
Log.d(TAG, "Received retry request for $packageName")
|
||||
// Clear the failure notification immediately for responsive feedback; the
|
||||
// download worker re-posts progress once it runs.
|
||||
NotificationUtil.clearAppNotification(context, packageName)
|
||||
AuroraApp.scope.launch(Dispatchers.IO) {
|
||||
downloadHelper.retryDownload(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ import com.aurora.store.util.CertUtil
|
||||
import com.aurora.store.util.NotificationUtil
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import com.aurora.store.util.PathUtil
|
||||
import com.aurora.store.util.Preferences
|
||||
import com.aurora.store.util.Preferences.PREFERENCE_NOTIFICATION_PROGRESS
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.io.File
|
||||
@@ -93,6 +95,12 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
private val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||
|
||||
// When the user opts out of progress notifications, the mandatory foreground notification
|
||||
// is kept minimal and per-tick progress refreshes are skipped. Read live so toggling the
|
||||
// setting takes effect on the next download.
|
||||
private val showProgress: Boolean
|
||||
get() = Preferences.getBoolean(context, PREFERENCE_NOTIFICATION_PROGRESS, true)
|
||||
|
||||
private var icon: Bitmap? = null
|
||||
private var totalBytes by Delegates.notNull<Long>()
|
||||
private var totalProgress = 0
|
||||
@@ -252,6 +260,10 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private suspend fun onSuccess(): Result {
|
||||
return withContext(NonCancellable) {
|
||||
return@withContext try {
|
||||
// Update the ongoing foreground notification to reflect the install phase,
|
||||
// so the user sees a clean "Downloading -> Installing" progression instead of
|
||||
// a stale download bar lingering at 100%.
|
||||
notifyStatus(DownloadStatus.INSTALLING, isProgress = true)
|
||||
appInstaller.getPreferredInstaller(notifyOnFallback = true).install(download)
|
||||
Result.success()
|
||||
} catch (exception: Exception) {
|
||||
@@ -510,7 +522,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notification = if (this::download.isInitialized) {
|
||||
val notification = if (this::download.isInitialized && showProgress) {
|
||||
NotificationUtil.getDownloadNotification(context, download, icon)
|
||||
} else {
|
||||
NotificationUtil.getDownloadNotification(context)
|
||||
@@ -537,18 +549,44 @@ class DownloadWorker @AssistedInject constructor(
|
||||
downloadDao.updateStatus(download.packageName, status)
|
||||
|
||||
when (status) {
|
||||
// Internal phases the user doesn't need a separate notification for: the ongoing
|
||||
// foreground progress notification already conveys that work is in progress.
|
||||
// Clear any stale per-app notification (e.g. a prior failure being retried) so it
|
||||
// doesn't linger.
|
||||
DownloadStatus.PURCHASING,
|
||||
DownloadStatus.VERIFYING,
|
||||
DownloadStatus.CANCELLED -> return
|
||||
DownloadStatus.CANCELLED -> {
|
||||
notificationManager.cancel(download.packageName.hashCode())
|
||||
return
|
||||
}
|
||||
|
||||
DownloadStatus.COMPLETED -> {
|
||||
// Mark progress as 100 manually to avoid race conditions
|
||||
download.progress = 100
|
||||
downloadDao.updateProgress(download.packageName, 100, 0, 0)
|
||||
|
||||
// Silently-installable apps install automatically and get a single
|
||||
// "installed" notification afterwards, so a separate "download complete"
|
||||
// notice is just noise. Only surface completion when the user must act on it
|
||||
// (tap to install).
|
||||
val needsUserAction = !AppInstaller.canInstallSilently(
|
||||
context,
|
||||
download.packageName,
|
||||
download.targetSdk
|
||||
)
|
||||
if (!needsUserAction) {
|
||||
notificationManager.cancel(download.packageName.hashCode())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// Skip detailed progress refreshes when the user has hidden progress; the minimal
|
||||
// foreground notification posted via getForegroundInfo keeps the download alive.
|
||||
if (isProgress && !showProgress) return
|
||||
|
||||
val notification = NotificationUtil.getDownloadNotification(
|
||||
context,
|
||||
download,
|
||||
@@ -559,6 +597,12 @@ class DownloadWorker @AssistedInject constructor(
|
||||
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
|
||||
notification
|
||||
)
|
||||
|
||||
// A failed download is a grouped child; reconcile the failure summary so a bulk
|
||||
// update collapses into a single "N apps failed" entry.
|
||||
if (status == DownloadStatus.FAILED) {
|
||||
NotificationUtil.refreshGroupSummaries(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aurora.store.util
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
@@ -22,58 +23,125 @@ import com.aurora.store.data.helper.DownloadHelper
|
||||
import com.aurora.store.data.installer.AppInstaller
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
import com.aurora.store.data.receiver.DownloadCancelReceiver
|
||||
import com.aurora.store.data.receiver.DownloadRetryReceiver
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.download.Download as AuroraDownload
|
||||
import com.aurora.store.data.room.update.Update
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
object NotificationUtil {
|
||||
|
||||
// Channel groups: headings the individual channels are filed under in system settings.
|
||||
private const val GROUP_CHANNELS_ACTIVITY = "com.aurora.store.channels.ACTIVITY"
|
||||
private const val GROUP_CHANNELS_ALERTS = "com.aurora.store.channels.ALERTS"
|
||||
|
||||
// Terminal install/failure notifications are bundled under a group so a bulk update
|
||||
// shows a single collapsible summary instead of one notification per app.
|
||||
private const val GROUP_INSTALLED = "com.aurora.store.INSTALLED"
|
||||
private const val GROUP_FAILED = "com.aurora.store.FAILED"
|
||||
|
||||
// Fixed IDs for the two group summaries. Kept well clear of the per-app IDs
|
||||
// (packageName.hashCode()) and the worker IDs (100/200/500/501).
|
||||
private const val SUMMARY_ID_INSTALLED = 1_000_001
|
||||
private const val SUMMARY_ID_FAILED = 1_000_002
|
||||
|
||||
// Successful installs are informational, so they expire on their own rather than
|
||||
// lingering. Failures are actionable and persist until handled.
|
||||
private val INSTALLED_TIMEOUT_MS = TimeUnit.HOURS.toMillis(6)
|
||||
|
||||
fun createNotificationChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = context.getSystemService<NotificationManager>()
|
||||
val channels = ArrayList<NotificationChannel>()
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_INSTALL,
|
||||
context.getString(R.string.notification_channel_install),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
setSound(null, null)
|
||||
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)
|
||||
}
|
||||
)
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_EXPORT,
|
||||
context.getString(R.string.notification_channel_export),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
|
||||
// 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)
|
||||
)
|
||||
)
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_ACCOUNT,
|
||||
context.getString(R.string.notification_channel_account),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
)
|
||||
|
||||
val channels = ArrayList<NotificationChannel>()
|
||||
|
||||
// Quiet, ongoing progress for active downloads. MIN keeps it collapsed and out
|
||||
// of the status bar where possible.
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_DOWNLOADS,
|
||||
context.getString(R.string.notification_channel_downloads),
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
).apply {
|
||||
group = GROUP_CHANNELS_ACTIVITY
|
||||
setSound(null, null)
|
||||
setShowBadge(false)
|
||||
}
|
||||
)
|
||||
|
||||
// Silent confirmation that an app finished installing/updating. LOW so it lands
|
||||
// in the shade without buzzing for every app during a bulk update.
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_INSTALL,
|
||||
context.getString(R.string.notification_channel_install),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
group = GROUP_CHANNELS_ACTIVITY
|
||||
setSound(null, null)
|
||||
}
|
||||
)
|
||||
|
||||
// New updates are available to install. DEFAULT but silent.
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_UPDATES,
|
||||
context.getString(R.string.notification_channel_updates),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
group = GROUP_CHANNELS_ACTIVITY
|
||||
setSound(null, null)
|
||||
}
|
||||
)
|
||||
notificationManager!!.createNotificationChannels(channels)
|
||||
|
||||
// Background app export. LOW since it's user-initiated and non-urgent.
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_EXPORT,
|
||||
context.getString(R.string.notification_channel_export),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
group = GROUP_CHANNELS_ACTIVITY
|
||||
setSound(null, null)
|
||||
}
|
||||
)
|
||||
|
||||
// Things the user genuinely needs to act on: failed downloads/installs, pending
|
||||
// user action and authentication prompts. HIGH so these aren't missed.
|
||||
channels.add(
|
||||
NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_ALERTS,
|
||||
context.getString(R.string.notification_channel_alerts),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
group = GROUP_CHANNELS_ALERTS
|
||||
}
|
||||
)
|
||||
|
||||
notificationManager.createNotificationChannels(channels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +159,14 @@ object NotificationUtil {
|
||||
largeIcon: Bitmap? = null,
|
||||
message: String? = null
|
||||
): Notification {
|
||||
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_DOWNLOADS)
|
||||
// Terminal failures go to the high-importance alerts channel so they aren't lost in
|
||||
// the silent downloads channel; everything else stays quiet.
|
||||
val channelId = if (download.status == DownloadStatus.FAILED) {
|
||||
Constants.NOTIFICATION_CHANNEL_ALERTS
|
||||
} else {
|
||||
Constants.NOTIFICATION_CHANNEL_DOWNLOADS
|
||||
}
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
builder.setSmallIcon(R.drawable.ic_notification_outlined)
|
||||
builder.setContentTitle(download.displayName)
|
||||
builder.setContentIntent(getContentIntentForDetails(context, download.packageName))
|
||||
@@ -122,6 +197,9 @@ object NotificationUtil {
|
||||
builder.setContentText(message ?: context.getString(R.string.download_failed))
|
||||
builder.color = Color.RED
|
||||
builder.setCategory(Notification.CATEGORY_ERROR)
|
||||
builder.setGroup(GROUP_FAILED)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
builder.addAction(getRetryAction(context, download.packageName))
|
||||
}
|
||||
|
||||
DownloadStatus.COMPLETED -> {
|
||||
@@ -175,6 +253,15 @@ object NotificationUtil {
|
||||
)
|
||||
}
|
||||
|
||||
DownloadStatus.INSTALLING -> {
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
builder.setContentText(context.getString(R.string.status_installing))
|
||||
builder.setOngoing(true)
|
||||
builder.setCategory(Notification.CATEGORY_PROGRESS)
|
||||
builder.setProgress(100, 100, true)
|
||||
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
return builder.build()
|
||||
@@ -190,6 +277,11 @@ object NotificationUtil {
|
||||
.setContentTitle(displayName)
|
||||
.setContentText(context.getString(R.string.installer_status_success))
|
||||
.setContentIntent(getContentIntentForDetails(context, packageName))
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
.setGroup(GROUP_INSTALLED)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
.setAutoCancel(true)
|
||||
.setTimeoutAfter(INSTALLED_TIMEOUT_MS)
|
||||
.build()
|
||||
|
||||
fun getInstallerStatusNotification(
|
||||
@@ -197,13 +289,158 @@ object NotificationUtil {
|
||||
packageName: String,
|
||||
displayName: String,
|
||||
content: String?
|
||||
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_INSTALL)
|
||||
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS)
|
||||
.setSmallIcon(R.drawable.ic_install)
|
||||
.setContentTitle(displayName)
|
||||
.setContentText(content)
|
||||
.setContentIntent(getContentIntentForDetails(context, packageName))
|
||||
.setCategory(Notification.CATEGORY_ERROR)
|
||||
.setGroup(GROUP_FAILED)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
.addAction(getRetryAction(context, packageName))
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Posts the grouped "app installed" notification and refreshes the group summary so a
|
||||
* bulk update collapses into a single "N apps installed" entry.
|
||||
*/
|
||||
fun notifyInstalled(context: Context, displayName: String, packageName: String) {
|
||||
val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||
notificationManager.notify(
|
||||
packageName.hashCode(),
|
||||
getInstallNotification(context, displayName, packageName)
|
||||
)
|
||||
refreshGroupSummaries(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the grouped install-failure notification (with a retry action) and refreshes the
|
||||
* failure group summary.
|
||||
*/
|
||||
fun notifyInstallFailed(
|
||||
context: Context,
|
||||
packageName: String,
|
||||
displayName: String,
|
||||
content: String?
|
||||
) {
|
||||
val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||
notificationManager.notify(
|
||||
packageName.hashCode(),
|
||||
getInstallerStatusNotification(context, packageName, displayName, content)
|
||||
)
|
||||
refreshGroupSummaries(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the per-app notification for [packageName] (e.g. once the user retries a failed
|
||||
* install) and reconciles the group summaries.
|
||||
*/
|
||||
fun clearAppNotification(context: Context, packageName: String) {
|
||||
context.getSystemService<NotificationManager>()!!.cancel(packageName.hashCode())
|
||||
refreshGroupSummaries(context)
|
||||
}
|
||||
|
||||
private fun getRetryAction(context: Context, packageName: String): NotificationCompat.Action {
|
||||
val intent = Intent(context, DownloadRetryReceiver::class.java).apply {
|
||||
putExtra(DownloadHelper.PACKAGE_NAME, packageName)
|
||||
}
|
||||
val pendingIntent = PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
packageName.hashCode().absoluteValue,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_updates,
|
||||
context.getString(R.string.action_retry),
|
||||
pendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the two group summaries from the currently-posted per-app notifications. Each
|
||||
* summary lists its apps via [NotificationCompat.InboxStyle] and is removed once no
|
||||
* children remain. Reading the live notifications (rather than tracking state ourselves)
|
||||
* keeps the summaries correct across retries, dismissals and process restarts.
|
||||
*/
|
||||
fun refreshGroupSummaries(context: Context) {
|
||||
// Grouping/summaries are only rendered from Android N onwards; on older versions a
|
||||
// summary would just show as an extra standalone notification, so leave the children
|
||||
// to display individually.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
|
||||
val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||
|
||||
refreshSummary(
|
||||
notificationManager = notificationManager,
|
||||
context = context,
|
||||
group = GROUP_INSTALLED,
|
||||
summaryId = SUMMARY_ID_INSTALLED,
|
||||
channelId = Constants.NOTIFICATION_CHANNEL_INSTALL,
|
||||
smallIcon = R.drawable.ic_install,
|
||||
titleRes = R.plurals.notification_installed_summary,
|
||||
timeoutMs = INSTALLED_TIMEOUT_MS,
|
||||
contentIntent = getContentIntentForMain(context, initialTab = 2)
|
||||
)
|
||||
refreshSummary(
|
||||
notificationManager = notificationManager,
|
||||
context = context,
|
||||
group = GROUP_FAILED,
|
||||
summaryId = SUMMARY_ID_FAILED,
|
||||
channelId = Constants.NOTIFICATION_CHANNEL_ALERTS,
|
||||
smallIcon = R.drawable.ic_download_fail,
|
||||
titleRes = R.plurals.notification_failed_summary,
|
||||
timeoutMs = null,
|
||||
contentIntent = getContentIntentForMain(context, initialTab = 2)
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun refreshSummary(
|
||||
notificationManager: NotificationManager,
|
||||
context: Context,
|
||||
group: String,
|
||||
summaryId: Int,
|
||||
channelId: String,
|
||||
smallIcon: Int,
|
||||
titleRes: Int,
|
||||
timeoutMs: Long?,
|
||||
contentIntent: PendingIntent?
|
||||
) {
|
||||
val children = notificationManager.activeNotifications.filter {
|
||||
it.notification.group == group && it.id != summaryId
|
||||
}
|
||||
|
||||
if (children.isEmpty()) {
|
||||
notificationManager.cancel(summaryId)
|
||||
return
|
||||
}
|
||||
|
||||
val titles = children.mapNotNull {
|
||||
it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||
}
|
||||
val title = context.resources.getQuantityString(titleRes, children.size, children.size)
|
||||
|
||||
val inboxStyle = NotificationCompat.InboxStyle().setBigContentTitle(title)
|
||||
titles.forEach { inboxStyle.addLine(it) }
|
||||
|
||||
val summary = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setContentTitle(title)
|
||||
.setContentText(titles.joinToString(", "))
|
||||
.setStyle(inboxStyle)
|
||||
.setGroup(group)
|
||||
.setGroupSummary(true)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.apply { timeoutMs?.let { setTimeoutAfter(it) } }
|
||||
.build()
|
||||
|
||||
notificationManager.notify(summaryId, summary)
|
||||
}
|
||||
|
||||
fun getUpdateNotification(context: Context): Notification =
|
||||
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
||||
.setSmallIcon(R.drawable.ic_updates)
|
||||
@@ -289,7 +526,7 @@ object NotificationUtil {
|
||||
.build()
|
||||
|
||||
fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification =
|
||||
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ACCOUNT)
|
||||
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS)
|
||||
.setSmallIcon(R.drawable.ic_account)
|
||||
.setContentTitle(context.getString(R.string.authentication_required_title))
|
||||
.setContentText(context.getString(R.string.authentication_required_unarchive))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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="dialog_title_self_update">"New update available"</string>
|
||||
<string name="dialog_desc_native_split">"You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root."</string>
|
||||
<string name="notification_channel_account">Account-related notification</string>
|
||||
<string name="notification_channel_alerts">Errors & 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_install">Install notification</string>
|
||||
<string name="notification_channel_downloads">Downloads notification</string>
|
||||
@@ -298,6 +300,13 @@
|
||||
<string name="title_no_network">"No network"</string>
|
||||
<string name="title_purchase_history">"Purchase history"</string>
|
||||
<string name="title_settings">"Settings"</string>
|
||||
<string name="title_notifications">"Notifications"</string>
|
||||
<string name="pref_notification_progress">Show download progress</string>
|
||||
<string name="pref_notification_progress_desc">Show detailed progress while downloading and updating apps. When off, a single quiet notification keeps downloads running in the background.</string>
|
||||
<string name="pref_notification_categories">Categories</string>
|
||||
<string name="pref_notification_categories_desc">Tune sound and importance for each kind of notification</string>
|
||||
<string name="pref_notification_disabled">Notifications are turned off</string>
|
||||
<string name="pref_notification_disabled_desc">Tap to enable notifications for Aurora Store</string>
|
||||
<string name="title_spoof_manager">"Spoof manager"</string>
|
||||
<string name="title_search_suggestion">"Search suggestion"</string>
|
||||
<string name="title_search_results">"Search results"</string>
|
||||
@@ -576,6 +585,14 @@
|
||||
<item quantity="one">Permission required</item>
|
||||
<item quantity="other">Permissions required</item>
|
||||
</plurals>
|
||||
<plurals name="notification_installed_summary">
|
||||
<item quantity="one">%d app installed</item>
|
||||
<item quantity="other">%d apps installed</item>
|
||||
</plurals>
|
||||
<plurals name="notification_failed_summary">
|
||||
<item quantity="one">%d app failed</item>
|
||||
<item quantity="other">%d apps failed</item>
|
||||
</plurals>
|
||||
|
||||
<!-- AppsGamesFragment -->
|
||||
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>
|
||||
|
||||
Reference in New Issue
Block a user