Merge branch 'dev' into 'master'

dev

See merge request AuroraOSS/AuroraStore!575
This commit is contained in:
Rahul Patel
2026-05-30 19:28:36 +05:30
44 changed files with 1950 additions and 278 deletions

View File

@@ -23,6 +23,10 @@ val lastCommitHash = providers.exec {
commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText.map { it.trim() }
val lastCommitTimestamp = providers.exec {
commandLine("git", "log", "-1", "--format=%ct")
}.standardOutput.asText.map { it.trim() }
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
@@ -69,6 +73,7 @@ configure<ApplicationExtension> {
testInstrumentationRunnerArguments["disableAnalytics"] = "true"
buildConfigField("String", "EXODUS_API_KEY", "\"bbe6ebae4ad45a9cbacb17d69739799b8df2c7ae\"")
buildConfigField("long", "BUILD_TIMESTAMP", "${lastCommitTimestamp.get()}L")
missingDimensionStrategy("device", "vanilla")
}
@@ -191,6 +196,7 @@ dependencies {
// AndroidX
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.browser)
implementation(libs.androidx.biometric)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.navigation3)
implementation(libs.androidx.preference.ktx)

View File

@@ -188,6 +188,11 @@
android:name=".data.receiver.DownloadCancelReceiver"
android:exported="false" />
<!-- Retry Download Receiver-->
<receiver
android:name=".data.receiver.DownloadRetryReceiver"
android:exported="false" />
<!-- SessionInstaller (as device owner) -->
<receiver
android:name=".data.receiver.DeviceOwnerReceiver"

View File

@@ -37,15 +37,27 @@ object Constants {
const val SHARE_URL = "https://play.google.com/store/apps/details?id="
const val UPDATE_URL_STABLE = "https://gitlab.com/AuroraOSS/AuroraStore/raw/master/updates.json"
const val UPDATE_URL_VANILLA =
"https://auroraoss.com/downloads/AuroraStore/Feeds/release_feed.json"
const val UPDATE_URL_NIGHTLY =
"https://auroraoss.com/downloads/AuroraStore/Feeds/nightly_feed.json"
const val NOTIFICATION_CHANNEL_EXPORT = "NOTIFICATION_CHANNEL_EXPORT"
const val NOTIFICATION_CHANNEL_INSTALL = "NOTIFICATION_CHANNEL_INSTALL"
// Channel IDs carry a version suffix where the importance changed from a previous
// release: Android ignores importance edits on an already-created channel, so a new ID
// is the only way to roll out a lower importance. Retired IDs are listed in
// [LEGACY_NOTIFICATION_CHANNELS] so they can be deleted on next launch.
const val NOTIFICATION_CHANNEL_EXPORT = "NOTIFICATION_CHANNEL_EXPORT_V2"
const val NOTIFICATION_CHANNEL_INSTALL = "NOTIFICATION_CHANNEL_INSTALLED"
const val NOTIFICATION_CHANNEL_DOWNLOADS = "NOTIFICATION_CHANNEL_DOWNLOADS"
const val NOTIFICATION_CHANNEL_UPDATES = "NOTIFICATION_CHANNEL_UPDATES"
const val NOTIFICATION_CHANNEL_ACCOUNT = "NOTIFICATION_CHANNEL_ACCOUNT"
const val NOTIFICATION_CHANNEL_ALERTS = "NOTIFICATION_CHANNEL_ALERTS"
// Channels removed or superseded by a higher-versioned ID; deleted on next launch.
val LEGACY_NOTIFICATION_CHANNELS = listOf(
"NOTIFICATION_CHANNEL_EXPORT",
"NOTIFICATION_CHANNEL_INSTALL",
"NOTIFICATION_CHANNEL_ACCOUNT"
)
const val GITLAB_URL = "https://gitlab.com/AuroraOSS/AuroraStore"
const val URL_DISPENSER = "https://auroraoss.com/api/auth"

View File

@@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.flowOn
fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo> = flow {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
var lastTotalBytesRead: Long = 0
var speed: Long = 0
@@ -23,6 +22,10 @@ fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo>
lastTotalBytesRead = totalBytesRead
}
// Cancel the timer even when the collector aborts mid-stream (e.g. the download is
// stopped), otherwise the timer thread would leak.
try {
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
out.flush()
@@ -32,5 +35,7 @@ fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo>
emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed))
bytes = read(buffer)
}
} finally {
timer.cancel()
}
}.flowOn(Dispatchers.IO)

View File

@@ -8,33 +8,51 @@ package com.aurora.store
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.getPackageName
import com.aurora.store.R
import com.aurora.store.compose.composition.LocalNetworkStatus
import com.aurora.store.compose.composition.LocalUI
import com.aurora.store.compose.composition.UI
import com.aurora.store.compose.navigation.NavDisplay
import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.theme.AuroraTheme
import com.aurora.store.compose.ui.lock.AppLockScreen
import com.aurora.store.data.AppLockManager
import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.data.providers.NetworkProvider
import com.aurora.store.data.receiver.MigrationReceiver
import com.aurora.store.util.AppLockAuthenticator
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
class ComposeActivity : FragmentActivity() {
@Inject lateinit var networkProvider: NetworkProvider
@Inject lateinit var appLockManager: AppLockManager
override fun onCreate(savedInstanceState: Bundle?) {
MigrationReceiver.runMigrationsIfRequired(this)
enableEdgeToEdge()
@@ -53,16 +71,85 @@ class ComposeActivity : ComponentActivity() {
val networkStatus by networkProvider.status.collectAsStateWithLifecycle(
initialValue = NetworkStatus.AVAILABLE
)
CompositionLocalProvider(
AuroraTheme {
var lockState by remember {
mutableStateOf(
if (appLockManager.shouldLock(this@ComposeActivity)) {
LockState.AUTHENTICATING
} else {
LockState.UNLOCKED
}
)
}
// Keep FLAG_SECURE on until unlocked; auto-prompt while authenticating
LaunchedEffect(lockState) {
when (lockState) {
LockState.AUTHENTICATING -> {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
promptUnlock(
onSuccess = { lockState = LockState.UNLOCKED },
onError = { lockState = LockState.LOCKED }
)
}
LockState.LOCKED -> window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
LockState.UNLOCKED ->
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
// Re-lock when returning from the background past the grace timeout
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> appLockManager.onBackgrounded()
Lifecycle.Event.ON_START ->
if (lockState == LockState.UNLOCKED &&
appLockManager.shouldLock(this@ComposeActivity)
) {
lockState = LockState.AUTHENTICATING
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
when (lockState) {
LockState.UNLOCKED -> CompositionLocalProvider(
LocalUI provides localUI,
LocalNetworkStatus provides networkStatus
) {
AuroraTheme {
NavDisplay(startDestination = startDestination)
}
// Plain surface behind the prompt so dismissing it doesn't flash the lock card
LockState.AUTHENTICATING -> Surface(modifier = Modifier.fillMaxSize()) {}
LockState.LOCKED -> AppLockScreen(
onUnlock = { lockState = LockState.AUTHENTICATING }
)
}
}
}
}
private fun promptUnlock(onSuccess: () -> Unit, onError: () -> Unit) {
AppLockAuthenticator.authenticate(
activity = this,
title = getString(R.string.app_lock_prompt_title),
subtitle = getString(R.string.app_lock_prompt_subtitle),
onSuccess = {
appLockManager.markUnlocked()
onSuccess()
},
onError = { onError() }
)
}
private fun resolveStartDestination(): Screen {
// Parcel-based navigation (e.g. from NotificationUtil)
@@ -91,4 +178,6 @@ class ComposeActivity : ComponentActivity() {
!Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding
else -> Screen.Splash()
}
private enum class LockState { AUTHENTICATING, LOCKED, UNLOCKED }
}

View File

@@ -61,7 +61,7 @@ fun DownloadListItem(modifier: Modifier = Modifier, download: Download, onClick:
)
},
trailing = when (download.status) {
DownloadStatus.COMPLETED -> {
DownloadStatus.COMPLETED, DownloadStatus.INSTALLED -> {
{
Icon(
painter = painterResource(R.drawable.ic_check),

View File

@@ -44,6 +44,8 @@ sealed class Destination {
data object NetworkPreference : Destination()
data object Dispenser : Destination()
data object UIPreference : Destination()
data object NotificationPreference : Destination()
data object UpdatesPreference : Destination()
data object SourceFilters : Destination()
data object SecurityPreference : Destination()
}

View File

@@ -48,11 +48,13 @@ 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
import com.aurora.store.compose.ui.preferences.installation.InstallerScreen
import com.aurora.store.compose.ui.preferences.network.NetworkPreferenceScreen
import com.aurora.store.compose.ui.preferences.security.SecurityPreferenceScreen
import com.aurora.store.compose.ui.preferences.updates.SourceFiltersScreen
import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen
import com.aurora.store.compose.ui.search.SearchScreen
@@ -160,8 +162,10 @@ 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)
}
}
@@ -282,8 +286,10 @@ fun NavDisplay(startDestination: NavKey) {
entry<Screen.Settings> { SettingsScreen(onNavigateTo = ::navigate) }
entry<Screen.NetworkPreference> { NetworkPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.UIPreference> { UIPreferenceScreen() }
entry<Screen.NotificationPreference> { NotificationPreferenceScreen() }
entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.SourceFilters> { SourceFiltersScreen() }
entry<Screen.SecurityPreference> { SecurityPreferenceScreen() }
}
)
}

View File

@@ -89,12 +89,18 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
data object UIPreference : Screen()
@Serializable
data object NotificationPreference : Screen()
@Serializable
data object UpdatesPreference : Screen()
@Serializable
data object SourceFilters : Screen()
@Serializable
data object SecurityPreference : Screen()
@Serializable
data class Splash(val packageName: String? = null) : Screen()

View File

@@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.lock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.aurora.store.R
import com.aurora.store.compose.preview.ThemePreviewProvider
/**
* Full-screen lock placeholder shown in place of the app content while the app is locked.
* The actual authentication prompt is driven by the hosting activity; [onUnlock] re-triggers
* it, e.g. after the user dismissed the prompt.
*/
@Composable
fun AppLockScreen(onUnlock: () -> Unit) {
Scaffold { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.ic_lock),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.padding(bottom = 16.dp)
)
Text(
text = stringResource(R.string.app_lock_locked_message),
modifier = Modifier.padding(bottom = 24.dp),
textAlign = TextAlign.Center
)
Button(onClick = onUnlock) {
Text(stringResource(R.string.app_lock_unlock))
}
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun AppLockScreenPreview() {
AppLockScreen(onUnlock = {})
}

View File

@@ -0,0 +1,174 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.preferences
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.core.net.toUri
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.aurora.Constants
import com.aurora.extensions.areNotificationsEnabled
import com.aurora.extensions.isOAndAbove
import com.aurora.store.R
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_NOTIFICATION_PROGRESS
import com.aurora.store.util.save
@Composable
fun NotificationPreferenceScreen() {
ScreenContent()
}
@Composable
private fun ScreenContent() {
val context = LocalContext.current
var notificationsEnabled by remember { mutableStateOf(context.areNotificationsEnabled()) }
var showProgress by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_NOTIFICATION_PROGRESS, true))
}
// Re-read the system notification state when returning from settings.
LifecycleResumeEffect(Unit) {
notificationsEnabled = context.areNotificationsEnabled()
onPauseOrDispose {}
}
// The channels users most likely want to fine-tune (sound, importance, on/off), paired
// with their display-name strings, deep-linked into the system per-channel settings.
val channels = listOf(
Constants.NOTIFICATION_CHANNEL_DOWNLOADS to R.string.notification_channel_downloads,
Constants.NOTIFICATION_CHANNEL_INSTALL to R.string.notification_channel_install,
Constants.NOTIFICATION_CHANNEL_UPDATES to R.string.notification_channel_updates,
Constants.NOTIFICATION_CHANNEL_ALERTS to R.string.notification_channel_alerts,
Constants.NOTIFICATION_CHANNEL_EXPORT to R.string.notification_channel_export
)
Scaffold(
topBar = {
TopAppBar(
title = stringResource(R.string.title_notifications)
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
if (!notificationsEnabled) {
item {
ListItem(
modifier = Modifier.clickable { openAppNotificationSettings(context) },
headlineContent = {
Text(stringResource(R.string.pref_notification_disabled))
},
supportingContent = {
Text(stringResource(R.string.pref_notification_disabled_desc))
}
)
}
item { HorizontalDivider() }
}
item {
ListItem(
modifier = Modifier.clickable {
showProgress = !showProgress
context.save(PREFERENCE_NOTIFICATION_PROGRESS, showProgress)
},
headlineContent = {
Text(stringResource(R.string.pref_notification_progress))
},
supportingContent = {
Text(stringResource(R.string.pref_notification_progress_desc))
},
trailingContent = {
Switch(
checked = showProgress,
onCheckedChange = { checked ->
showProgress = checked
context.save(PREFERENCE_NOTIFICATION_PROGRESS, checked)
}
)
}
)
}
// Per-channel system settings are only meaningful on Android O+ where channels
// exist; on older versions the app-level toggle above is the only control.
if (isOAndAbove) {
item { HorizontalDivider() }
item {
ListItem(
headlineContent = {
Text(stringResource(R.string.pref_notification_categories))
},
supportingContent = {
Text(stringResource(R.string.pref_notification_categories_desc))
}
)
}
channels.forEach { (channelId, nameRes) ->
item {
ListItem(
modifier = Modifier.clickable {
openChannelSettings(context, channelId)
},
headlineContent = { Text(stringResource(nameRes)) }
)
}
}
}
}
}
}
private fun openAppNotificationSettings(context: android.content.Context) {
val intent = if (isOAndAbove) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData("package:${context.packageName}".toUri())
}
context.startActivity(intent)
}
private fun openChannelSettings(context: android.content.Context, channelId: String) {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
context.startActivity(intent)
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun NotificationPreferenceScreenPreview() {
ScreenContent()
}

View File

@@ -88,6 +88,20 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) {
headlineContent = { Text(stringResource(R.string.pref_ui_title)) }
)
}
item {
ListItem(
modifier = Modifier.clickable {
onNavigateTo(Destination.NotificationPreference)
},
leadingContent = {
Icon(
painter = painterResource(R.drawable.ic_notification_settings),
contentDescription = null
)
},
headlineContent = { Text(stringResource(R.string.title_notifications)) }
)
}
item {
ListItem(
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },
@@ -112,6 +126,18 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) {
headlineContent = { Text(stringResource(R.string.title_updates)) }
)
}
item {
ListItem(
modifier = Modifier.clickable { onNavigateTo(Destination.SecurityPreference) },
leadingContent = {
Icon(
painter = painterResource(R.drawable.ic_lock),
contentDescription = null
)
},
headlineContent = { Text(stringResource(R.string.title_security)) }
)
}
}
}
}

View File

@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.preferences.security
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.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 com.aurora.extensions.toast
import com.aurora.store.R
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.util.AppLockAuthenticator
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
import com.aurora.store.util.save
@Composable
fun SecurityPreferenceScreen() {
ScreenContent()
}
@Composable
private fun ScreenContent() {
val context = LocalContext.current
var appLockEnabled by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false))
}
fun setAppLock(enabled: Boolean) {
// Refuse to enable the lock when the device has no biometric or screen-lock to fall back on
if (enabled && !AppLockAuthenticator.canAuthenticate(context)) {
context.toast(R.string.app_lock_no_credential)
return
}
appLockEnabled = enabled
context.save(PREFERENCE_APP_LOCK_ENABLED, enabled)
}
Scaffold(
topBar = { TopAppBar(title = stringResource(R.string.title_security)) }
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
item {
ListItem(
modifier = Modifier.clickable { setAppLock(!appLockEnabled) },
headlineContent = { Text(stringResource(R.string.app_lock_title)) },
supportingContent = { Text(stringResource(R.string.app_lock_summary)) },
trailingContent = {
Switch(
checked = appLockEnabled,
onCheckedChange = { setAppLock(it) }
)
}
)
}
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview
@Composable
private fun SecurityPreferenceScreenPreview() {
ScreenContent()
}

View File

@@ -55,6 +55,7 @@ import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog
import com.aurora.store.data.model.UpdateMode
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE
@@ -62,6 +63,7 @@ import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_METERE
import com.aurora.store.util.Preferences.PREFERENCE_FILTER_AURORA_ONLY
import com.aurora.store.util.Preferences.PREFERENCE_FILTER_FDROID
import com.aurora.store.util.Preferences.PREFERENCE_FILTER_INSTALLERS
import com.aurora.store.util.Preferences.PREFERENCE_SELF_UPDATE_ENABLED
import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_AUTO
import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_CHECK_INTERVAL
import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED
@@ -79,6 +81,7 @@ fun UpdatesPreferenceScreen(
onScheduleAutomatedCheck = { viewModel.updateHelper.scheduleAutomatedCheck() },
onUpdateAutomatedCheck = { viewModel.updateHelper.updateAutomatedCheck() },
onCheckUpdatesNow = { viewModel.updateHelper.checkUpdatesNow() },
onDeleteSelfUpdate = { viewModel.updateHelper.deleteSelfUpdate() },
onNavigateTo = onNavigateTo
)
}
@@ -89,6 +92,7 @@ private fun ScreenContent(
onScheduleAutomatedCheck: () -> Unit = {},
onUpdateAutomatedCheck: () -> Unit = {},
onCheckUpdatesNow: () -> Unit = {},
onDeleteSelfUpdate: () -> Unit = {},
onNavigateTo: (Destination) -> Unit = {}
) {
val context = LocalContext.current
@@ -109,6 +113,10 @@ private fun ScreenContent(
var updatesExtended by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_UPDATES_EXTENDED))
}
val selfUpdateSupported = remember { PackageUtil.isSelfUpdateSupported(context) }
var selfUpdateEnabled by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_SELF_UPDATE_ENABLED, true))
}
// Re-read source-filter prefs after returning from SourceFiltersScreen.
val lifecycleOwner = LocalLifecycleOwner.current
@@ -335,6 +343,30 @@ private fun ScreenContent(
}
)
}
if (selfUpdateSupported) {
item {
fun onSelfUpdateChanged(enabled: Boolean) {
selfUpdateEnabled = enabled
context.save(PREFERENCE_SELF_UPDATE_ENABLED, enabled)
if (enabled) onCheckUpdatesNow() else onDeleteSelfUpdate()
}
ListItem(
modifier = Modifier.clickable {
onSelfUpdateChanged(!selfUpdateEnabled)
},
headlineContent = { Text(stringResource(R.string.pref_self_update)) },
supportingContent = {
Text(stringResource(R.string.pref_self_update_desc))
},
trailingContent = {
Switch(
checked = selfUpdateEnabled,
onCheckedChange = ::onSelfUpdateChanged
)
}
)
}
}
}
}
}

View File

@@ -59,19 +59,21 @@ fun UpdatesScreen(
}
val groupedUpdates = remember(updateMap) {
val self = mutableListOf<Map.Entry<Update, Download?>>()
val main = mutableListOf<Map.Entry<Update, Download?>>()
val approval = mutableListOf<Map.Entry<Update, Download?>>()
val incompatible = mutableListOf<Map.Entry<Update, Download?>>()
updateMap?.entries?.forEach { entry ->
when {
entry.key.isSelfUpdate(context) -> self += entry
entry.key.isIncompatible -> incompatible += entry
entry.key.requiresOwnershipTransfer(context) -> approval += entry
else -> main += entry
}
}
Triple(main.toList(), approval.toList(), incompatible.toList())
listOf(self.toList(), main.toList(), approval.toList(), incompatible.toList())
}
val (mainEntries, approvalEntries, incompatibleEntries) = groupedUpdates
val (selfEntries, mainEntries, approvalEntries, incompatibleEntries) = groupedUpdates
val mainAnyActive = mainEntries.any { it.value.isActive() }
val approvalAnyActive = approvalEntries.any { it.value.isActive() }
@@ -110,6 +112,29 @@ fun UpdatesScreen(
dimensionResource(R.dimen.spacing_medium)
)
) {
if (selfEntries.isNotEmpty()) {
item(key = "header_self") {
SectionHeader(
title = stringResource(R.string.updates_self_header),
subtitle = stringResource(R.string.updates_self_desc)
)
}
items(
items = selfEntries,
key = { "self-${it.key.packageName}" }
) { (update, download) ->
AppUpdateItem(
update = update,
download = download,
// Served from the Aurora OSS feed, not Play — there is
// no app details page to open.
onClick = {},
onUpdate = { onRequestUpdate(update) },
onCancel = { onCancelUpdate(update.packageName) }
)
}
}
if (mainEntries.isNotEmpty()) {
item(key = "header_main") {
val title = "${mainEntries.size} " + stringResource(

View File

@@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data
import android.content.Context
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_ENABLED
import com.aurora.store.util.Preferences.PREFERENCE_APP_LOCK_TIMEOUT
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tracks the app-lock state at process scope. The unlocked flag lives only in memory, so a
* fresh process (cold start or process death) always starts locked. Re-locking after the
* app is backgrounded is governed by a short grace [timeout] to avoid re-prompting during
* transient stops such as the system install dialog or the biometric sheet itself.
*/
@Singleton
class AppLockManager @Inject constructor() {
companion object {
// Grace period before a backgrounded app re-locks, in seconds
private const val DEFAULT_TIMEOUT_SECONDS = 30
}
private var unlocked = false
private var backgroundedAt = 0L
/**
* Whether the app should currently present the lock screen, i.e. the feature is enabled
* and the session is not (still) unlocked within the grace [timeout].
*/
fun shouldLock(context: Context): Boolean {
if (!Preferences.getBoolean(context, PREFERENCE_APP_LOCK_ENABLED, false)) return false
if (!unlocked) return true
if (backgroundedAt == 0L) return false
val elapsed = System.currentTimeMillis() - backgroundedAt
return elapsed >= timeoutMillis(context)
}
/** Marks the current session as authenticated. */
fun markUnlocked() {
unlocked = true
backgroundedAt = 0L
}
/** Records when the app went to the background so the grace timeout can be measured. */
fun onBackgrounded() {
if (unlocked) backgroundedAt = System.currentTimeMillis()
}
private fun timeoutMillis(context: Context): Long {
val seconds = Preferences.getInteger(
context,
PREFERENCE_APP_LOCK_TIMEOUT,
DEFAULT_TIMEOUT_SECONDS
)
return seconds * 1000L
}
}

View File

@@ -2,14 +2,20 @@ package com.aurora.store.data.helper
import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkRequest
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App
import com.aurora.store.AuroraApp
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadDao
@@ -18,6 +24,7 @@ import com.aurora.store.data.room.update.Update
import com.aurora.store.data.work.DownloadWorker
import com.aurora.store.util.PathUtil
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
@@ -32,7 +39,8 @@ import kotlinx.coroutines.launch
*/
class DownloadHelper @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadDao: DownloadDao
private val downloadDao: DownloadDao,
private val appInstaller: AppInstaller
) {
companion object {
@@ -68,13 +76,50 @@ class DownloadHelper @Inject constructor(
cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList())
}.invokeOnCompletion {
observeDownloads()
observeInstalls()
}
}
/**
* Advances a download row through the installer phase so its history reflects whether the
* app actually installed, not just that the bytes finished downloading:
* - [InstallerEvent.Installing] moves a [DownloadStatus.COMPLETED] row to
* [DownloadStatus.INSTALLING];
* - [InstallerEvent.Installed] marks it [DownloadStatus.INSTALLED] (kept so the user can
* still export the APK);
* - [InstallerEvent.Failed] reverts an in-progress install back to
* [DownloadStatus.COMPLETED] so the downloaded files can be re-installed without
* re-downloading.
*/
private fun observeInstalls() {
AuroraApp.events.installerEvent.onEach { event ->
val existing = getDownload(event.packageName) ?: return@onEach
when (event) {
is InstallerEvent.Installing -> if (existing.status == DownloadStatus.COMPLETED) {
downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLING)
}
is InstallerEvent.Installed -> if (existing.status != DownloadStatus.INSTALLED) {
downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLED)
}
is InstallerEvent.Failed -> if (existing.status == DownloadStatus.INSTALLING) {
downloadDao.updateStatus(event.packageName, DownloadStatus.COMPLETED)
}
else -> {}
}
}.launchIn(AuroraApp.scope)
}
private fun observeDownloads() {
downloadDao.downloads().onEach { list ->
try {
if (list.none { it.status == DownloadStatus.DOWNLOADING }) {
// Serialize downloads: only start the next queued item once nothing else is
// actively purchasing/downloading/verifying. Previously this only checked for
// DOWNLOADING, so a worker in PURCHASING/VERIFYING didn't count and a second
// download could start concurrently and clobber the shared notification.
if (list.none { it.status in DownloadStatus.processing }) {
list.find { it.status == DownloadStatus.QUEUED }
?.let { queuedDownload ->
Log.i(TAG, "Enqueued download worker for ${queuedDownload.packageName}")
@@ -92,7 +137,7 @@ class DownloadHelper @Inject constructor(
* @param app [App] to download
*/
suspend fun enqueueApp(app: App) {
downloadDao.insert(Download.fromApp(app))
enqueue(Download.fromApp(app))
}
/**
@@ -100,7 +145,7 @@ class DownloadHelper @Inject constructor(
* @param update [Update] to download
*/
suspend fun enqueueUpdate(update: Update) {
downloadDao.insert(Download.fromUpdate(update))
enqueue(Download.fromUpdate(update))
}
/**
@@ -108,7 +153,57 @@ class DownloadHelper @Inject constructor(
* @param externalApk [ExternalApk] to download
*/
suspend fun enqueueStandalone(externalApk: ExternalApk) {
downloadDao.insert(Download.fromExternalApk(externalApk))
enqueue(Download.fromExternalApk(externalApk))
}
/**
* Inserts a new download row, but only when a (re)download is actually needed. For an
* existing record of the same version this:
* - **installs without re-downloading** if the files are already downloaded & verified
* (e.g. the user missed the system install prompt, or the periodic update check runs
* again before a pending install completed); or
* - **skips** entirely if the download is still active (queued/purchasing/downloading/
* verifying), so the periodic [UpdateWorker] and repeated user taps can't reset it back
* to [DownloadStatus.QUEUED] and re-download it.
*
* A genuinely newer version, or a previously failed/cancelled download whose files are
* gone, falls through and is (re)enqueued.
*/
private suspend fun enqueue(download: Download) {
val existing = getDownload(download.packageName)
if (existing != null && existing.versionCode == download.versionCode) {
if (existing.canInstall(context)) {
Log.i(TAG, "${download.packageName} already downloaded, installing directly")
runCatching {
appInstaller.getPreferredInstaller(notifyOnFallback = true).install(existing)
}.onFailure { Log.e(TAG, "Failed to install ${download.packageName}", it) }
return
}
if (existing.isActive) {
Log.i(
TAG,
"Skipping enqueue for ${download.packageName}; already ${existing.status}"
)
return
}
}
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)
}
/**
@@ -118,6 +213,8 @@ class DownloadHelper @Inject constructor(
suspend fun cancelDownload(packageName: String) {
Log.i(TAG, "Cancelling download for $packageName")
WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$packageName")
// Abandon any session already staged for install so we don't leak it.
runCatching { appInstaller.getPreferredInstaller().cancelInstall(packageName) }
downloadDao.updateStatus(packageName, DownloadStatus.CANCELLED)
}
@@ -197,11 +294,24 @@ class DownloadHelper @Inject constructor(
.putString(PACKAGE_NAME, download.packageName)
.build()
// Require connectivity so the worker doesn't spin up (or keep running) without a
// network, and back off exponentially so transient failures resume cleanly once the
// connection returns instead of hammering the server.
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
.addTag(DOWNLOAD_WORKER)
.addTag("$PACKAGE_NAME:${download.packageName}")
.addTag("$VERSION_CODE:${download.versionCode}")
.addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.setInputData(inputData)
.build()

View File

@@ -14,8 +14,10 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.aurora.extensions.TAG
import com.aurora.store.AuroraApp
import com.aurora.store.BuildConfig
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.model.BuildType
import com.aurora.store.data.model.UpdateMode
import com.aurora.store.data.room.update.IgnoredUpdate
import com.aurora.store.data.room.update.IgnoredUpdateDao
@@ -185,6 +187,14 @@ class UpdateHelper @Inject constructor(
updateDao.deleteAll()
}
/**
* Immediately drops Aurora Store's own update row, e.g. when the user turns the
* self-update preference off. The periodic check won't re-add it while disabled.
*/
fun deleteSelfUpdate() {
AuroraApp.scope.launch { deleteUpdate(BuildConfig.APPLICATION_ID) }
}
/**
* Hide all future updates for [packageName] until [unignore] is called.
*/
@@ -243,6 +253,13 @@ class UpdateHelper @Inject constructor(
private suspend fun deleteInvalidUpdates() {
updateDao.updates().firstOrNull()?.forEach { update ->
// Nightly builds share a static version code, so the generic "up to date"
// check would wrongly drop a freshly found self-update. The worker owns the
// self-update lifecycle (re-checked by build timestamp); cleanup happens via
// the install event instead.
if (update.isSelfUpdate(context) && BuildType.CURRENT == BuildType.NIGHTLY) {
return@forEach
}
if (!update.isInstalled(context) || update.isUpToDate(context)) {
deleteUpdate(update.packageName)
}

View File

@@ -25,13 +25,17 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.content.pm.PackageInfoCompat
import com.aurora.Constants.PACKAGE_NAME_GMS
import com.aurora.extensions.TAG
import com.aurora.extensions.getUpdateOwnerPackageNameCompat
import com.aurora.extensions.isOAndAbove
import com.aurora.extensions.isPAndAbove
import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.toast
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.data.installer.ShizukuInstaller.Companion.SHIZUKU_PACKAGE_NAME
import com.aurora.store.data.installer.base.IInstaller
import com.aurora.store.data.model.Installer
@@ -172,8 +176,15 @@ class AppInstaller @Inject constructor(
fun hasShizukuOrSui(context: Context): Boolean = isOAndAbove &&
(PackageUtil.isInstalled(context, SHIZUKU_PACKAGE_NAME) || Sui.isSui())
fun hasShizukuPerm(): Boolean =
// Shizuku.checkSelfPermission() throws when the binder is not alive (Shizuku
// disabled/not running), so guard on pingBinder() and swallow any failure to let
// callers fall back gracefully instead of crashing.
fun hasShizukuPerm(): Boolean = try {
Shizuku.pingBinder() &&
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
} catch (_: Exception) {
false
}
fun uninstall(context: Context, packageName: String) {
val intent = Intent().apply {
@@ -196,7 +207,15 @@ class AppInstaller @Inject constructor(
fun getMicroGInstaller(): IInstaller = microGInstaller
fun getPreferredInstaller(): IInstaller = when (getCurrentInstaller(context)) {
/**
* Returns the installer for the user's chosen mode, transparently falling back to the
* default (session) installer when that mode is currently unavailable, e.g. the chosen
* Shizuku installer when Shizuku is disabled.
* @param notifyOnFallback Whether to inform the user via a toast when a fallback happens
*/
fun getPreferredInstaller(notifyOnFallback: Boolean = false): IInstaller {
val selected = getCurrentInstaller(context)
val installer = when (selected) {
Installer.SESSION -> sessionInstaller
Installer.NATIVE -> nativeInstaller
@@ -223,4 +242,12 @@ class AppInstaller @Inject constructor(
defaultInstaller
}
}
if (selected != Installer.SESSION && installer === defaultInstaller) {
Log.i(TAG, "$selected installer unavailable, falling back to session installer")
if (notifyOnFallback) context.toast(R.string.installer_fallback_session)
}
return installer
}
}

View File

@@ -182,6 +182,16 @@ class SessionInstaller @Inject constructor(
}
}
override fun cancelInstall(packageName: String) {
val sessionSet = enqueuedSessions
.find { set -> set.any { it.packageName == packageName } } ?: return
Log.i(TAG, "Abandoning staged session(s) for $packageName")
sessionSet.forEach { runCatching { packageInstaller.abandonSession(it.sessionId) } }
enqueuedSessions.remove(sessionSet)
removeFromInstallQueue(packageName)
}
private fun stageInstall(
packageName: String,
versionCode: Long,
@@ -189,7 +199,12 @@ class SessionInstaller @Inject constructor(
): Int? {
val resolvedPackageName = sharedLibPkgName.ifBlank { packageName }
val sessionParams = buildSessionParams(resolvedPackageName)
// Size hint lets the system reserve space (and evict its cache) for the staged copy.
val totalSize = runCatching {
getFiles(packageName, versionCode, sharedLibPkgName).sumOf { it.length() }
}.getOrDefault(0L)
val sessionParams = buildSessionParams(resolvedPackageName, totalSize)
val sessionId = packageInstaller.createSession(sessionParams)
val session = packageInstaller.openSession(sessionId)
@@ -216,9 +231,10 @@ class SessionInstaller @Inject constructor(
}
}
private fun buildSessionParams(packageName: String): SessionParams =
private fun buildSessionParams(packageName: String, totalSize: Long = 0L): SessionParams =
SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(packageName)
if (totalSize > 0) setSize(totalSize)
setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO)
if (isNAndAbove) {
setOriginatingUid(Process.myUid())

View File

@@ -26,4 +26,11 @@ interface IInstaller {
fun clearQueue()
fun isAlreadyQueued(packageName: String): Boolean
fun removeFromInstallQueue(packageName: String)
/**
* Abandons any staged-but-uncommitted install session for [packageName] so cancelling
* a download doesn't leak a [android.content.pm.PackageInstaller] session. Default no-op
* for installers that don't stage sessions.
*/
fun cancelInstall(packageName: String) {}
}

View File

@@ -19,13 +19,11 @@
package com.aurora.store.data.installer.base
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageInstaller
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import com.aurora.extensions.TAG
import com.aurora.store.AuroraApp
import com.aurora.store.BuildConfig
@@ -42,13 +40,7 @@ abstract class InstallerBase(private val context: Context) : IInstaller {
companion object {
fun notifyInstallation(context: Context, displayName: String, packageName: String) {
val notificationManager = context.getSystemService<NotificationManager>()
val notification = NotificationUtil.getInstallNotification(
context,
displayName,
packageName
)
notificationManager!!.notify(packageName.hashCode(), notification)
NotificationUtil.notifyInstalled(context, displayName, packageName)
}
fun getErrorString(context: Context, status: Int): String = when (status) {

View File

@@ -11,10 +11,20 @@ enum class DownloadStatus(@StringRes val localized: Int) {
QUEUED(R.string.status_queued),
UNAVAILABLE(R.string.status_unavailable),
VERIFYING(R.string.status_verifying),
PURCHASING(R.string.preparing_to_install);
PURCHASING(R.string.preparing_to_install),
INSTALLING(R.string.status_installing),
INSTALLED(R.string.status_installed);
companion object {
val finished = listOf(FAILED, CANCELLED, COMPLETED)
val running = listOf(QUEUED, PURCHASING, DOWNLOADING)
val finished = setOf(FAILED, CANCELLED, COMPLETED, INSTALLED)
val running = setOf(QUEUED, PURCHASING, DOWNLOADING)
/**
* States in which a download worker is actively occupying the (single) download
* slot — purchasing, transferring bytes or verifying. Used to serialize downloads:
* the next [QUEUED] item is only started once none of these are in progress, so
* concurrent workers can't clobber the shared foreground/progress notification.
*/
val processing = setOf(PURCHASING, DOWNLOADING, VERIFYING)
}
}

View File

@@ -1,20 +1,6 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* Aurora Store is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* Aurora Store is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.model
@@ -24,51 +10,63 @@ import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Artwork
import com.aurora.gplayapi.data.models.EncodedCertificateSet
import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.util.CertUtil
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Self-update feed entry returned by `release_feed.json` (vanilla release) and
* `nightly_feed.json` (nightly), both served from the Aurora OSS server.
*
* The producer encodes numeric fields as JSON strings, so the raw fields below stay
* `String` and we expose typed `Long` accessors that tolerate blank values. Decoding
* with `Long`s would otherwise blow up on the first request.
*/
@Serializable
data class SelfUpdate(
@SerialName("version_name") var versionName: String = String(),
@SerialName("version_code") var versionCode: Long = 0,
@SerialName("aurora_build") var auroraBuild: String = String(),
@SerialName("fdroid_build") var fdroidBuild: String = String(),
@SerialName("updated_on") var updatedOn: String = String(),
val changelog: String = String(),
val size: Long = 0L,
val timestamp: Long = 0L
@SerialName("version_name")
val versionName: String = "",
@SerialName("version_code")
val versionCodeRaw: String = "0",
@SerialName("download_url")
val downloadUrl: String = "",
val changelog: String = "",
@SerialName("size")
val sizeRaw: String = "0",
@SerialName("last_commit")
val lastCommit: String = "",
@SerialName("updated_on")
val updatedOn: String = "",
@SerialName("timestamp")
val timestampRaw: String = "0"
) {
companion object {
private const val BASE_URL = "https://gitlab.com/AuroraOSS/AuroraStore/-/raw/master"
val versionCode: Long get() = versionCodeRaw.toLongOrNull() ?: 0L
val size: Long get() = sizeRaw.toLongOrNull() ?: 0L
val timestamp: Long get() = timestampRaw.toLongOrNull() ?: 0L
fun toApp(selfUpdate: SelfUpdate, context: Context): App {
// Keep paths updated with fastlane data on project
val icon = "fastlane/metadata/android/en-US/images/icon.png"
val downloadURL = if (CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID)) {
selfUpdate.fdroidBuild
} else {
selfUpdate.auroraBuild
}
return App(
/**
* Maps the feed entry onto a regular [App] so it can flow through the normal
* update pipeline ([com.aurora.store.data.room.update.Update.fromApp] →
* download → install). The certificate set is the currently installed app's own
* hashes so [com.aurora.store.data.room.update.Update.hasValidCert] holds and the
* update is never filtered out as untrusted.
*/
fun toApp(context: Context): App = App(
packageName = context.packageName,
versionCode = selfUpdate.versionCode,
versionName = selfUpdate.versionName,
changes = selfUpdate.changelog,
size = selfUpdate.size,
updatedOn = selfUpdate.updatedOn,
versionCode = versionCode,
versionName = versionName,
changes = changelog,
size = size,
updatedOn = updatedOn,
displayName = context.getString(R.string.app_name),
developerName = "Rahul Kumar Patel",
iconArtwork = Artwork(url = "$BASE_URL/$icon"),
iconArtwork = Artwork(url = ICON_URL),
fileList = mutableListOf(
PlayFile(
name = "${context.packageName}.apk",
url = downloadURL,
size = selfUpdate.size
url = downloadUrl,
size = size
)
),
isFree = true,
@@ -80,6 +78,11 @@ data class SelfUpdate(
EncodedCertificateSet(certificateSet = it, sha256 = String())
}.toMutableList()
)
}
companion object {
// Kept in sync with the fastlane metadata on the project.
private const val ICON_URL =
"https://gitlab.com/AuroraOSS/AuroraStore/-/raw/master/" +
"fastlane/metadata/android/en-US/images/icon.png"
}
}

View File

@@ -18,7 +18,6 @@
*/
package com.aurora.store.data.receiver
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -26,7 +25,6 @@ import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
import android.util.Log
import androidx.core.content.IntentCompat
import androidx.core.content.getSystemService
import com.aurora.extensions.TAG
import com.aurora.extensions.runOnUiThread
import com.aurora.store.AuroraApp
@@ -92,14 +90,12 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() {
displayName: String,
status: Int
) {
val notificationManager = context.getSystemService<NotificationManager>()
val notification = NotificationUtil.getInstallerStatusNotification(
NotificationUtil.notifyInstallFailed(
context,
packageName,
displayName,
InstallerBase.getErrorString(context, status)
)
notificationManager?.notify(packageName.hashCode(), notification)
}
internal fun promptUser(context: Context, intent: Intent) {

View File

@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.aurora.extensions.TAG
import com.aurora.store.AuroraApp
import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.util.NotificationUtil
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Re-queues a previously failed download/install when the user taps "Retry" on its
* (grouped) failure notification.
*/
@AndroidEntryPoint
class DownloadRetryReceiver : BroadcastReceiver() {
@Inject
lateinit var downloadHelper: DownloadHelper
override fun onReceive(context: Context, intent: Intent?) {
val packageName = intent?.getStringExtra(DownloadHelper.PACKAGE_NAME) ?: ""
if (packageName.isNotBlank()) {
Log.d(TAG, "Received retry request for $packageName")
// Clear the failure notification immediately for responsive feedback; the
// download worker re-posts progress once it runs.
NotificationUtil.clearAppNotification(context, packageName)
AuroraApp.scope.launch(Dispatchers.IO) {
downloadHelper.retryDownload(packageName)
}
}
}
}

View File

@@ -43,6 +43,13 @@ data class Download(
val isRunning get() = status in DownloadStatus.running
private val isSuccessful get() = status == DownloadStatus.COMPLETED
/**
* `true` while the download is queued, purchasing, downloading or verifying, i.e.
* the pipeline is actively working on it. Unlike [isRunning] this also covers
* [DownloadStatus.VERIFYING], which sits between downloading and completion.
*/
val isActive get() = isRunning || status == DownloadStatus.VERIFYING
companion object {
fun fromApp(app: App): Download = Download(
app.packageName,
@@ -108,7 +115,10 @@ data class Download(
}
fun canInstall(context: Context): Boolean {
if (!isSuccessful) return false
val dir = PathUtil.getAppDownloadDir(context, packageName, versionCode)
return isSuccessful && dir.listFiles() != null
// Require at least one actual APK on disk, not just that the directory exists —
// an empty/partially-cleaned directory must not look installable.
return dir.listFiles()?.any { it.name.endsWith(".apk") } == true
}
}

View File

@@ -8,6 +8,8 @@ import androidx.work.ExistingPeriodicWorkPolicy.KEEP
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.util.PathUtil
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -17,12 +19,14 @@ import java.util.concurrent.TimeUnit.HOURS
import java.util.concurrent.TimeUnit.MINUTES
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlinx.coroutines.flow.first
/**
* A periodic worker to automatically clear the old downloads cache periodically.
*/
@HiltWorker
class CacheWorker @AssistedInject constructor(
private val downloadDao: DownloadDao,
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
@@ -58,6 +62,17 @@ class CacheWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
Log.i(TAG, "Cleaning cache")
// Files for downloads that are still in-flight or downloaded & awaiting install
// must be protected from the age-based purge, otherwise a download the user hasn't
// installed yet (e.g. they missed the system prompt) would lose its files and have
// to be re-downloaded. Keyed by packageName -> versionCode.
val protectedVersions = runCatching {
downloadDao.downloads().first()
.filter { it.isActive || it.status == DownloadStatus.COMPLETED }
.map { it.packageName to it.versionCode }
.toSet()
}.getOrDefault(emptySet())
PathUtil.getOldDownloadDirectories(context).filter { it.exists() }.forEach { dir ->
// Downloads
Log.i(TAG, "Deleting old unused download directory: $dir")
@@ -75,10 +90,14 @@ class CacheWorker @AssistedInject constructor(
download.listFiles()!!.forEach { versionCode ->
// 20240325
val isProtected = (download.name to versionCode.name.toLongOrNull()) in
protectedVersions
if (versionCode.listFiles().isNullOrEmpty()) {
// Purge empty non-accessible directory
Log.i(TAG, "Removing empty directory for ${download.name}, ${versionCode.name}")
versionCode.deleteRecursively()
} else if (isProtected) {
Log.i(TAG, "Keeping ${download.name} (${versionCode.name}); install pending")
} else {
versionCode.deleteIfOld()
}

View File

@@ -11,6 +11,7 @@ import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.storage.StorageManager
import android.util.Log
import androidx.core.content.getSystemService
import androidx.core.graphics.scale
@@ -21,6 +22,7 @@ import androidx.work.WorkInfo.Companion.STOP_REASON_USER
import androidx.work.WorkerParameters
import com.aurora.extensions.TAG
import com.aurora.extensions.copyTo
import com.aurora.extensions.isOAndAbove
import com.aurora.extensions.isPAndAbove
import com.aurora.extensions.isQAndAbove
import com.aurora.extensions.isSAndAbove
@@ -44,10 +46,16 @@ 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
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection.HTTP_FORBIDDEN
import java.net.HttpURLConnection.HTTP_GONE
import java.net.HttpURLConnection.HTTP_PARTIAL
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -77,17 +85,31 @@ class DownloadWorker @AssistedInject constructor(
companion object {
private const val NOTIFICATION_ID: Int = 200
// Upper bound on automatic WorkManager retries for transient (network) failures
// before the download is marked as failed and left for the user to retry.
private const val MAX_DOWNLOAD_RETRIES = 5
}
private lateinit var download: Download
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
private var downloadedBytes = 0L
// Absolute paths of files already verified during the download pass, so the final
// verification gate doesn't hash large APKs a second time.
private val verifiedFiles = mutableSetOf<String>()
inner class NoNetworkException : Exception(context.getString(R.string.title_no_network))
inner class NothingToDownloadException : Exception(context.getString(R.string.purchase_no_file))
inner class DownloadFailedException : Exception(context.getString(R.string.download_failed))
@@ -97,6 +119,11 @@ class DownloadWorker @AssistedInject constructor(
inner class VerificationFailedException :
Exception(context.getString(R.string.verification_failed))
inner class ExpiredUrlException : Exception(context.getString(R.string.download_failed))
inner class InsufficientStorageException :
Exception(context.getString(R.string.download_failed_storage))
override suspend fun doWork(): Result {
super.doWork()
@@ -173,6 +200,15 @@ class DownloadWorker @AssistedInject constructor(
downloadDao.updateFiles(download.packageName, download.fileList)
downloadDao.updateSharedLibs(download.packageName, download.sharedLibs)
// Fail fast (and let the system free its cache) if there isn't room for the download,
// instead of dying mid-write with a partial file. Only the not-yet-downloaded bytes
// need to fit.
try {
ensureStorageAvailable(totalBytes - downloadedBytesOnDisk(files))
} catch (exception: Exception) {
return onFailure(exception)
}
// Download files
try {
for (file in files) {
@@ -184,24 +220,34 @@ class DownloadWorker @AssistedInject constructor(
download.downloadedFiles++
}
} catch (exception: Exception) {
if (exception is DownloadCancelledException) {
Log.i(TAG, "Download cancelled for ${download.packageName}")
// Try to delete all downloaded files
// Only purge partial files on a genuine user/app cancellation. A stop caused
// by lost connectivity, quota or device-state must keep the partials so the
// retry resumes instead of re-downloading from scratch (this was the source of
// downloads appearing to "restart" on a flaky network).
if (exception is DownloadCancelledException && isCancelledByUser()) {
Log.i(TAG, "Download cancelled by user for ${download.packageName}")
runCatching { files.forEach { deleteFile(it) } }
}
return onFailure(exception)
}
// Report failure if download was stopped or failed
if (isStopped) return onFailure(DownloadFailedException())
// A stop that isn't a user cancellation (e.g. connectivity constraint) should be
// retried with the partials intact rather than treated as a hard failure.
if (isStopped) return onFailure(DownloadCancelledException())
// Verify downloaded files
// Verify downloaded files (skipping any already verified during the download pass)
try {
notifyStatus(DownloadStatus.VERIFYING)
files.forEach { file -> require(verifyFile(file)) }
files.forEach { file ->
val path = PathUtil.getLocalFile(context, file, download).absolutePath
if (path !in verifiedFiles) require(verifyFile(file))
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to verify ${download.packageName}", exception)
// Drop the corrupt files so the next attempt re-downloads them clean instead
// of resuming from a poisoned offset.
runCatching { files.forEach { deleteFile(it) } }
return onFailure(VerificationFailedException())
}
@@ -214,7 +260,11 @@ class DownloadWorker @AssistedInject constructor(
private suspend fun onSuccess(): Result {
return withContext(NonCancellable) {
return@withContext try {
appInstaller.getPreferredInstaller().install(download)
// 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) {
Log.e(TAG, "Failed to install ${download.packageName}", exception)
@@ -223,12 +273,60 @@ class DownloadWorker @AssistedInject constructor(
}
}
/**
* Whether the current stop/cancellation was initiated by the user (or the app on the
* user's behalf) rather than by the system (connectivity/quota/device-state). Uses the
* S+ [stopReason] when available and otherwise falls back to the persisted status, which
* [DownloadHelper.cancelDownload] sets to [DownloadStatus.CANCELLED] before cancelling
* the work — making this reliable below Android 12 too.
*/
private suspend fun isCancelledByUser(): Boolean {
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP)
if (isSAndAbove && stopReason in cancelReasons) return true
return runCatching {
downloadDao.getDownload(download.packageName).status == DownloadStatus.CANCELLED
}.getOrDefault(false)
}
/**
* Transient errors worth retrying once connectivity returns. Walks the cause chain so a
* wrapped network error is still recognised.
*/
private fun isRetryable(throwable: Throwable?): Boolean = when (throwable) {
null -> false
is NoNetworkException,
is SocketException,
is SocketTimeoutException,
is UnknownHostException,
// Expired URLs were re-purchased by clearing the file list; retrying re-fetches them.
is ExpiredUrlException -> true
else -> isRetryable(throwable.cause)
}
private suspend fun onFailure(exception: Exception): Result {
return withContext(NonCancellable) {
Log.i(TAG, "Job failed: ${download.packageName}", exception)
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP)
if (isSAndAbove && stopReason in cancelReasons) {
val cancelledByUser = isCancelledByUser()
// Retry transient failures (lost connectivity, system-initiated stops) with
// backoff, keeping any partial download for resume. The network constraint on
// the work request means the retry only runs once connectivity is back.
val isSystemStop = exception is DownloadCancelledException && !cancelledByUser
if (!cancelledByUser &&
(isRetryable(exception) || isSystemStop) &&
runAttemptCount < MAX_DOWNLOAD_RETRIES
) {
Log.w(
TAG,
"Transient failure for ${download.packageName}, " +
"retrying (attempt $runAttemptCount)"
)
return@withContext Result.retry()
}
if (cancelledByUser) {
notifyStatus(DownloadStatus.CANCELLED)
} else {
when (exception) {
@@ -297,28 +395,63 @@ class DownloadWorker @AssistedInject constructor(
if (file.exists() && verifyFile(gFile)) {
Log.i(TAG, "$file is already downloaded!")
downloadedBytes += file.length()
verifiedFiles.add(file.absolutePath)
return@withContext true
}
try {
val tmpFileSuffix = ".tmp"
val tmpFile = File(file.absolutePath + tmpFileSuffix)
// Download as a temporary file to avoid installing corrupted files
val isNewFile = tmpFile.createNewFile()
val tmpFile = File(file.absolutePath + ".tmp")
val existingBytes = if (tmpFile.exists()) tmpFile.length() else 0L
val okHttpClient = httpClient as HttpClient
val headers = mutableMapOf<String, String>()
if (!isNewFile) {
Log.i(TAG, "$tmpFile has an unfinished download, resuming!")
downloadedBytes += tmpFile.length()
headers["Range"] = "bytes=${tmpFile.length()}-"
if (existingBytes > 0) {
Log.i(TAG, "$tmpFile has an unfinished download, requesting resume!")
headers["Range"] = "bytes=$existingBytes-"
}
okHttpClient.call(gFile.url, headers).body.byteStream().use { input ->
FileOutputStream(tmpFile, !isNewFile).use {
input.copyTo(it, gFile.size).collect { info -> onProgress(info) }
val response = okHttpClient.call(gFile.url, headers)
if (!response.isSuccessful) {
val code = response.code
response.close()
// Play download URLs are short-lived; a 403/410 means ours expired while
// the download sat queued. Drop the stale file lists so the retry
// re-purchases fresh URLs instead of hammering the dead one.
if (code == HTTP_FORBIDDEN || code == HTTP_GONE) {
Log.w(TAG, "Download URL for ${download.packageName} expired (code=$code)")
downloadDao.updateFiles(download.packageName, emptyList())
downloadDao.updateSharedLibs(
download.packageName,
download.sharedLibs.map { it.copy(fileList = emptyList()) }
)
throw ExpiredUrlException()
}
throw DownloadFailedException()
}
// Only resume when the server actually honored the Range request (206). If
// it replied 200 with the full body we must overwrite from the start,
// otherwise the full payload would be appended onto the existing partial and
// silently corrupt the file.
val resuming = existingBytes > 0 && response.code == HTTP_PARTIAL
if (resuming) {
downloadedBytes += existingBytes
} else if (existingBytes > 0) {
Log.w(
TAG,
"Server ignored Range for $tmpFile (code=${response.code}), restarting"
)
}
response.body.byteStream().use { input ->
FileOutputStream(tmpFile, resuming).use {
input.copyTo(it, gFile.size).collect { info ->
// Abort promptly mid-file when stopped, instead of only checking
// between files (a single split can be hundreds of MB).
if (isStopped) throw CancellationException("Download stopped")
onProgress(info)
}
}
}
@@ -389,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)
@@ -416,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,
@@ -438,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)
}
}
/**
@@ -452,6 +617,10 @@ class DownloadWorker @AssistedInject constructor(
val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256
val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256
if (algorithm == Algorithm.SHA1) {
Log.w(TAG, "No SHA-256 for ${gFile.name}, falling back to SHA-1")
}
if (expectedSha.isBlank()) return false
return withContext(Dispatchers.IO) {
@@ -489,4 +658,44 @@ class DownloadWorker @AssistedInject constructor(
Log.i(TAG, "Deleted Temp: $tmpFile")
}
}
/**
* Bytes already present on disk (final or partial .tmp) for [files], so the storage check
* only requires room for what's still left to fetch.
*/
private fun downloadedBytesOnDisk(files: List<PlayFile>): Long = files.sumOf { gFile ->
val file = PathUtil.getLocalFile(context, gFile, download)
val tmpFile = File(file.absolutePath + ".tmp")
when {
file.exists() -> file.length()
tmpFile.exists() -> tmpFile.length()
else -> 0L
}
}
/**
* Ensures there's room for [requiredBytes] before downloading, throwing
* [InsufficientStorageException] otherwise. On Android O+ this also asks the system to
* evict its own cache to make space, per the storage guidelines.
*/
private fun ensureStorageAvailable(requiredBytes: Long) {
if (requiredBytes <= 0) return
val dir = PathUtil.getDownloadDirectory(context).apply { mkdirs() }
if (isOAndAbove) {
val storageManager = context.getSystemService<StorageManager>()!!
try {
val uuid = storageManager.getUuidForPath(dir)
if (storageManager.getAllocatableBytes(uuid) < requiredBytes) {
throw InsufficientStorageException()
}
storageManager.allocateBytes(uuid, requiredBytes)
} catch (exception: IOException) {
Log.e(TAG, "Failed to allocate space for ${download.packageName}", exception)
throw InsufficientStorageException()
}
} else if (dir.usableSpace < requiredBytes) {
throw InsufficientStorageException()
}
}
}

View File

@@ -7,24 +7,30 @@ package com.aurora.store.data.work
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.net.Uri
import android.provider.DocumentsContract
import android.util.Log
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aurora.extensions.isQAndAbove
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.util.NotificationUtil
import com.aurora.store.util.PathUtil
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.io.File
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@@ -34,7 +40,8 @@ import java.util.zip.ZipOutputStream
@HiltWorker
class ExportWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters
@Assisted workerParams: WorkerParameters,
private val downloadDao: DownloadDao
) : CoroutineWorker(context, workerParams) {
companion object {
@@ -48,6 +55,9 @@ class ExportWorker @AssistedInject constructor(
private const val NOTIFICATION_ID = 500
private const val NOTIFICATION_ID_FGS = 501
// Large copy buffer to keep throughput high when bundling multi-GB exports
private const val BUFFER_SIZE = 1024 * 1024
/**
* Exports the given download to the URI
* @param download Download to export
@@ -67,7 +77,12 @@ class ExportWorker @AssistedInject constructor(
.build()
Log.i(TAG, "Exporting download for ${download.packageName}/${download.versionCode}")
WorkManager.getInstance(context).enqueue(oneTimeWorkRequest)
// Keep any in-progress export for the same app so re-tapping does not queue duplicates
WorkManager.getInstance(context).enqueueUniqueWork(
"$TAG/${download.packageName}",
ExistingWorkPolicy.KEEP,
oneTimeWorkRequest
)
}
}
@@ -83,26 +98,59 @@ class ExportWorker @AssistedInject constructor(
if (packageName.isNullOrEmpty() || versionCode == -1L) {
Log.e(TAG, "Input data is corrupt, bailing out!")
notifyStatus(displayName ?: String(), uri, false)
return Result.failure()
return fail(displayName ?: String(), uri)
}
try {
copyDownloadedApp(packageName, versionCode, uri)
val download = downloadDao.getDownload(packageName)
// The download row is keyed by package name only, so guard against it having
// been replaced by a manual download of a different version between enqueue and
// execution; the old version's file list is no longer available to export.
if (download.versionCode != versionCode) {
Log.e(TAG, "Download for $packageName is no longer version $versionCode, bailing!")
return fail(displayName ?: packageName, uri)
}
val entries = PathUtil.getExportableFiles(context, download)
if (entries.isEmpty()) {
// Nothing on disk to bundle, e.g. files were auto-deleted after install
Log.e(TAG, "No files to export for $packageName, was it auto-deleted?")
return fail(displayName ?: packageName, uri)
}
bundleFiles(displayName ?: packageName, entries, uri)
notifyStatus(displayName ?: packageName, uri)
} catch (exception: Exception) {
Log.e(TAG, "Failed to export $packageName", exception)
notifyStatus(displayName ?: packageName, uri, false)
return Result.failure()
return fail(displayName ?: packageName, uri)
}
return Result.success()
}
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
NOTIFICATION_ID_FGS,
NotificationUtil.getExportNotification(context)
)
override suspend fun getForegroundInfo(): ForegroundInfo =
exportForegroundInfo(inputData.getString(DISPLAY_NAME))
private fun exportForegroundInfo(displayName: String?, progress: Int = -1): ForegroundInfo {
val notification = NotificationUtil.getExportNotification(context, displayName, progress)
return if (isQAndAbove) {
ForegroundInfo(NOTIFICATION_ID_FGS, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(NOTIFICATION_ID_FGS, notification)
}
}
/**
* Deletes the partially written (or empty) export document and notifies failure, so a
* failed export does not leave a corrupt zip behind at the user-chosen location.
*/
private fun fail(packageName: String, uri: Uri): Result {
runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
.onFailure { Log.w(TAG, "Failed to delete incomplete export at $uri", it) }
notifyStatus(packageName, uri, false)
return Result.failure()
}
private fun notifyStatus(packageName: String, uri: Uri, success: Boolean = true) {
notificationManager.notify(
@@ -116,23 +164,49 @@ class ExportWorker @AssistedInject constructor(
)
}
private fun copyDownloadedApp(packageName: String, versionCode: Long, uri: Uri) = bundleAllAPKs(
PathUtil.getAppDownloadDir(context, packageName, versionCode).listFiles()!!.toList(),
uri
)
/**
* Updates the ongoing foreground notification to reflect export [progress] (0-100).
*/
private suspend fun notifyProgress(displayName: String, progress: Int) {
setForeground(exportForegroundInfo(displayName, progress))
}
/**
* Bundles all the given APKs to a zip file
* @param fileList List of APKs to add to the zip
* @param uri [Uri] of the file to write the APKs
* Bundles the given files into a zip written to the [uri], preserving each file's
* relative path within the archive so shared libraries and OBB/patch files retain
* their layout, while reporting progress through the foreground notification.
*
* Entries are stored without compression: APKs and OBBs are already compressed, so
* deflating them wastes CPU for no size gain and makes large bundles needlessly slow.
* @param displayName App name shown in the progress notification
* @param entries Map of zip entry name to the file to write
* @param uri [Uri] of the file to write the bundle to
*/
private fun bundleAllAPKs(fileList: List<File>, uri: Uri) {
ZipOutputStream(context.contentResolver.openOutputStream(uri)).use { zipOutput ->
fileList.forEach { file ->
file.inputStream().use { input ->
val zipEntry = ZipEntry(file.name)
zipOutput.putNextEntry(zipEntry)
input.copyTo(zipOutput)
private suspend fun bundleFiles(displayName: String, entries: Map<String, File>, uri: Uri) {
val totalBytes = entries.values.sumOf { it.length() }.coerceAtLeast(1)
var writtenBytes = 0L
var lastProgress = -1
val output = context.contentResolver.openOutputStream(uri)!!.buffered(BUFFER_SIZE)
ZipOutputStream(output).use { zipOutput ->
zipOutput.setLevel(Deflater.NO_COMPRESSION)
val buffer = ByteArray(BUFFER_SIZE)
entries.forEach { (entryName, file) ->
file.inputStream().buffered(BUFFER_SIZE).use { input ->
zipOutput.putNextEntry(ZipEntry(entryName))
var read = input.read(buffer)
while (read >= 0) {
zipOutput.write(buffer, 0, read)
writtenBytes += read
// Throttle to whole-percent changes to avoid being rate-limited
val progress = ((writtenBytes * 100) / totalBytes).toInt()
if (progress > lastProgress) {
lastProgress = progress
notifyProgress(displayName, progress)
}
read = input.read(buffer)
}
zipOutput.closeEntry()
}
}

View File

@@ -15,7 +15,6 @@ import com.aurora.extensions.isHyperOS
import com.aurora.extensions.isIgnoringBatteryOptimizations
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.BuildConfig
import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.helper.UpdateHelper
@@ -23,6 +22,7 @@ import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.BuildType
import com.aurora.store.data.model.SelfUpdate
import com.aurora.store.data.model.UpdateMode
import com.aurora.store.data.network.HttpClient
import com.aurora.store.data.providers.AccountProvider
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.BlacklistProvider
@@ -32,6 +32,7 @@ import com.aurora.store.util.CertUtil
import com.aurora.store.util.NotificationUtil
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_SELF_UPDATE_ENABLED
import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_AUTO
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -41,18 +42,22 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* A worker to check for updates for installed apps based on saved authentication data,
* filters and the auto-updates mode selected by the user. The repeat interval
* is configurable by the user, defaulting to 3 hours with a flex time of 30 minutes.
* A worker that drives periodic app-update checks. The repeat interval is configurable
* by the user, defaulting to 3 hours with a flex time of 30 minutes.
*
* Aurora Store's own update is fetched from the bundled release/nightly feed and added
* to the regular update list (see [getSelfUpdate]); from there it reuses the standard
* download + install pipeline. It is never auto-installed silently — the user triggers
* it from the Updates tab.
*
* Avoid using this worker directly and prefer using [UpdateHelper] instead.
* @see AuthWorker
*/
@HiltWorker
class UpdateWorker @AssistedInject constructor(
private val httpClient: HttpClient,
private val json: Json,
private val blacklistProvider: BlacklistProvider,
private val httpClient: IHttpClient,
private val updateDao: UpdateDao,
private val downloadHelper: DownloadHelper,
private val authProvider: AuthProvider,
@@ -61,11 +66,18 @@ class UpdateWorker @AssistedInject constructor(
@Assisted workerParams: WorkerParameters
) : AuthWorker(authProvider, context, workerParams) {
private val notificationID = 100
companion object {
private const val NOTIFICATION_ID = 100
}
private val canSelfUpdate = !CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID) &&
!CertUtil.isAppGalleryApp(context, BuildConfig.APPLICATION_ID) &&
BuildType.CURRENT != BuildType.DEBUG
/**
* `true` when the build supports self-update ([PackageUtil.isSelfUpdateSupported])
* and the user hasn't opted out via the Settings toggle. Read each check so flipping
* the preference takes effect on the next run.
*/
private val canSelfUpdate: Boolean
get() = PackageUtil.isSelfUpdateSupported(context) &&
Preferences.getBoolean(context, PREFERENCE_SELF_UPDATE_ENABLED, true)
private val isAuroraOnlyFilterEnabled: Boolean
get() = Preferences.getBoolean(context, Preferences.PREFERENCE_FILTER_AURORA_ONLY, false)
@@ -124,7 +136,8 @@ class UpdateWorker @AssistedInject constructor(
return Result.success()
}
// Clean the update list to prepare for installing
// Clean the update list to prepare for installing. Aurora Store's own update
// is never installed silently — the user triggers it from the Updates tab.
val filteredUpdates = updates
.filter { it.hasValidCert }
.filterNot { it.isSelfUpdate(context) }
@@ -152,7 +165,7 @@ class UpdateWorker @AssistedInject constructor(
}
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
notificationID,
NOTIFICATION_ID,
NotificationUtil.getUpdateNotification(context)
)
@@ -206,7 +219,18 @@ class UpdateWorker @AssistedInject constructor(
.filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) }
.toMutableList()
if (canSelfUpdate) getSelfUpdate()?.let { updates.add(it) }
// Aurora Store's own update comes from the feed, not Play. When one is
// offered, add it; otherwise drop any stale self-update row. This is the
// cleanup path for the row (nightly self-updates are exempt from
// deleteInvalidUpdates, and the install event isn't delivered reliably when
// the app replaces itself), so a previously shown self-update doesn't linger
// after we've already updated to it.
val selfUpdate = if (canSelfUpdate) getSelfUpdate() else null
if (selfUpdate != null) {
updates.add(selfUpdate)
} else {
updateDao.delete(context.packageName)
}
return@withContext updates.map {
Update.fromApp(
@@ -219,15 +243,15 @@ class UpdateWorker @AssistedInject constructor(
}
/**
* Checks and returns updates for Aurora Store if available
* Fetches Aurora Store's own update from the bundled release/nightly feed and maps
* it onto an [App] so it joins the regular update list. Nightly version codes never
* bump, so newness is decided by the build timestamp there; release uses the version
* code. Best-effort: any failure logs and yields no update.
*/
private suspend fun getSelfUpdate(): App? {
return withContext(Dispatchers.IO) {
private suspend fun getSelfUpdate(): App? = withContext(Dispatchers.IO) {
val updateUrl = when (BuildType.CURRENT) {
BuildType.RELEASE -> Constants.UPDATE_URL_STABLE
BuildType.RELEASE -> Constants.UPDATE_URL_VANILLA
BuildType.NIGHTLY -> Constants.UPDATE_URL_NIGHTLY
else -> {
Log.i(TAG, "Self-updates are not available for this build!")
return@withContext null
@@ -235,42 +259,31 @@ class UpdateWorker @AssistedInject constructor(
}
try {
val response = httpClient.get(updateUrl, mapOf())
val selfUpdate = json.decodeFromString<SelfUpdate>(String(response.responseBytes))
val selfUpdate = httpClient.call(updateUrl).use {
json.decodeFromString<SelfUpdate>(it.body.string())
}
val isUpdate = when (BuildType.CURRENT) {
BuildType.NIGHTLY,
val isNewer = when (BuildType.CURRENT) {
BuildType.RELEASE -> selfUpdate.versionCode > BuildConfig.VERSION_CODE
BuildType.NIGHTLY -> selfUpdate.timestamp > BuildConfig.BUILD_TIMESTAMP
else -> false
}
if (isUpdate) {
if (CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID)) {
if (selfUpdate.fdroidBuild.isNotEmpty()) {
return@withContext SelfUpdate.toApp(selfUpdate, context)
}
} else if (selfUpdate.auroraBuild.isNotEmpty()) {
return@withContext SelfUpdate.toApp(selfUpdate, context)
} else {
Log.e(TAG, "Update file is missing!")
return@withContext null
}
if (isNewer && selfUpdate.downloadUrl.isNotBlank()) {
return@withContext selfUpdate.toApp(context)
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to check self-updates", exception)
return@withContext null
}
Log.i(TAG, "No self-updates found!")
return@withContext null
}
}
private fun notifyUpdates(updates: List<Update>) {
with(context.getSystemService<NotificationManager>()!!) {
notify(
notificationID,
NOTIFICATION_ID,
NotificationUtil.getUpdateNotification(context, updates)
)
}

View File

@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.util
import android.app.KeyguardManager
import android.content.Context
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
/**
* Thin wrapper over [BiometricPrompt] that authenticates with the device biometric *or* the
* device credential (PIN / pattern / password). Devices without a fingerprint sensor — TVs,
* older phones — therefore fall back to the lockscreen credential automatically.
*
* Combining a biometric with [DEVICE_CREDENTIAL] is only supported by the prompt on API 30+;
* on older releases the deprecated `setDeviceCredentialAllowed` provides the same behaviour.
*/
object AppLockAuthenticator {
private val supportsCombinedAuthenticators = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
/**
* Whether the device can satisfy an app-lock challenge. True when a biometric or device
* credential is enrolled, or — for the pre-API-30 fallback path — the device simply has a
* secure lockscreen set.
*/
fun canAuthenticate(context: Context): Boolean {
val authenticators = if (supportsCombinedAuthenticators) {
BIOMETRIC_STRONG or DEVICE_CREDENTIAL
} else {
BIOMETRIC_STRONG
}
if (BiometricManager.from(context).canAuthenticate(authenticators) ==
BiometricManager.BIOMETRIC_SUCCESS
) {
return true
}
// Pre-API-30 path: no enrolled biometric, but a PIN/pattern/password works as fallback
return context.getSystemService<KeyguardManager>()?.isDeviceSecure == true
}
/**
* Shows the authentication prompt. [onSuccess] fires on a successful unlock; [onError]
* fires on a non-recoverable error or user cancellation (the prompt itself handles
* transient failures like a wrong fingerprint).
*/
fun authenticate(
activity: FragmentActivity,
title: String,
subtitle: String,
onSuccess: () -> Unit,
onError: (CharSequence) -> Unit
) {
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) =
onSuccess()
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) =
onError(errString)
}
val prompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(activity),
callback
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.apply {
if (supportsCombinedAuthenticators) {
setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
} else {
@Suppress("DEPRECATION")
setDeviceCredentialAllowed(true)
}
}
.build()
prompt.authenticate(promptInfo)
}
}

View File

@@ -2,6 +2,7 @@ package com.aurora.store.util
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
@@ -22,58 +23,125 @@ import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.receiver.DownloadCancelReceiver
import com.aurora.store.data.receiver.DownloadRetryReceiver
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.Download as AuroraDownload
import com.aurora.store.data.room.update.Update
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
object NotificationUtil {
// Channel groups: headings the individual channels are filed under in system settings.
private const val GROUP_CHANNELS_ACTIVITY = "com.aurora.store.channels.ACTIVITY"
private const val GROUP_CHANNELS_ALERTS = "com.aurora.store.channels.ALERTS"
// Terminal install/failure notifications are bundled under a group so a bulk update
// shows a single collapsible summary instead of one notification per app.
private const val GROUP_INSTALLED = "com.aurora.store.INSTALLED"
private const val GROUP_FAILED = "com.aurora.store.FAILED"
// Fixed IDs for the two group summaries. Kept well clear of the per-app IDs
// (packageName.hashCode()) and the worker IDs (100/200/500/501).
private const val SUMMARY_ID_INSTALLED = 1_000_001
private const val SUMMARY_ID_FAILED = 1_000_002
// Successful installs are informational, so they expire on their own rather than
// lingering. Failures are actionable and persist until handled.
private val INSTALLED_TIMEOUT_MS = TimeUnit.HOURS.toMillis(6)
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService<NotificationManager>()
val 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)
@@ -273,16 +510,23 @@ object NotificationUtil {
.build()
}
fun getExportNotification(context: Context): Notification =
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT)
fun getExportNotification(
context: Context,
displayName: String? = null,
progress: Int = -1
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT)
.setSmallIcon(R.drawable.ic_file_copy)
.setContentTitle(context.getString(R.string.export_app_title))
.setContentTitle(displayName ?: context.getString(R.string.export_app_title))
.setContentText(context.getString(R.string.export_app_summary))
.setProgress(100, progress.coerceAtLeast(0), progress < 0)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_PROGRESS)
.setSilent(true)
.build()
fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification =
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ACCOUNT)
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS)
.setSmallIcon(R.drawable.ic_account)
.setContentTitle(context.getString(R.string.authentication_required_title))
.setContentText(context.getString(R.string.authentication_required_unarchive))

View File

@@ -36,6 +36,8 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import com.aurora.Constants.FLAVOUR_PRELOAD
import com.aurora.Constants.FLAVOUR_VANILLA
import com.aurora.Constants.PACKAGE_NAME_APP_GALLERY
import com.aurora.Constants.PACKAGE_NAME_GMS
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE
@@ -48,6 +50,7 @@ import com.aurora.extensions.isVAndAbove
import com.aurora.extensions.isValidApp
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.data.model.BuildType
import java.util.Locale
object PackageUtil {
@@ -184,6 +187,19 @@ object PackageUtil {
}
}
/**
* Build-level eligibility for Aurora Store's self-update: vanilla / preload flavors
* only, never debug, and never an F-Droid-signed build. Huawei is excluded by the
* flavor check. The user-facing toggle gates this further at runtime.
*/
fun isSelfUpdateSupported(context: Context): Boolean {
val flavorEligible = BuildConfig.FLAVOR == FLAVOUR_VANILLA ||
BuildConfig.FLAVOR == FLAVOUR_PRELOAD
return flavorEligible &&
BuildType.CURRENT != BuildType.DEBUG &&
!CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID)
}
/**
* Confirm if MicroG bundle is installed
* Considering the following:

View File

@@ -21,6 +21,7 @@ package com.aurora.store.util
import android.content.Context
import android.os.Environment
import androidx.core.content.pm.PackageInfoCompat
import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.data.room.download.Download
import java.io.File
@@ -84,6 +85,69 @@ object PathUtil {
}
}
/**
* Resolves every on-disk file belonging to a finished [Download] (base/split APKs,
* shared-library APKs and OBB/patch files), mapped to the relative path it should
* occupy inside an exported zip bundle.
*
* APKs are taken from the download cache, falling back to the APKs of the installed
* app when the cache was cleared (e.g. auto-deleted after install). They keep their
* layout relative to the app's download directory (shared libraries stay under
* `libraries/<packageName>/`). OBB/patch files live outside the cache, survive the
* cleanup, and are placed under `Android/obb/<packageName>/` so they can be restored
* to their on-device location.
*
* Files that are missing on disk are skipped, as OBB/patch files are optional.
*/
fun getExportableFiles(context: Context, download: Download): Map<String, File> {
val appDir = getAppDownloadDir(context, download.packageName, download.versionCode)
val playFiles = download.fileList + download.sharedLibs.flatMap { it.fileList }
val (obbPlayFiles, apkPlayFiles) = playFiles.partition {
it.type == PlayFile.Type.OBB || it.type == PlayFile.Type.PATCH
}
val apkFiles = apkPlayFiles.associate { playFile ->
val file = getLocalFile(context, playFile, download)
file.relativeTo(appDir).invariantSeparatorsPath to file
}.filterValues { it.exists() }
.ifEmpty { getInstalledApkFiles(context, download.packageName, download.versionCode) }
val obbFiles = obbPlayFiles.associate { playFile ->
val file = getLocalFile(context, playFile, download)
"Android/obb/${download.packageName}/${file.name}" to file
}.filterValues { it.exists() }
return apkFiles + obbFiles
}
/**
* Resolves the base and split APKs of the installed [packageName] from their on-device
* locations, used as a fallback when the downloaded files are no longer cached. Returns
* an empty map unless the installed version matches [versionCode], so a different
* installed version is never exported by mistake.
*/
private fun getInstalledApkFiles(
context: Context,
packageName: String,
versionCode: Long
): Map<String, File> {
val packageInfo = runCatching {
PackageUtil.getPackageInfo(context, packageName)
}.getOrNull() ?: return emptyMap()
if (PackageInfoCompat.getLongVersionCode(packageInfo) != versionCode) return emptyMap()
val appInfo = packageInfo.applicationInfo ?: return emptyMap()
val apkPaths = buildList {
appInfo.sourceDir?.let { add(it) }
appInfo.splitSourceDirs?.let { addAll(it.filterNotNull()) }
}
return apkPaths.map { File(it) }
.filter { it.exists() }
.associateBy { it.name }
}
fun getZipFile(context: Context, packageName: String, versionCode: Long): File = File(
getAppDownloadDir(
context,

View File

@@ -43,6 +43,8 @@ object Preferences {
const val PREFERENCE_AUTO_DELETE = "PREFERENCE_AUTO_DELETE"
const val PREFERENCE_NOTIFICATION_PROGRESS = "PREFERENCE_NOTIFICATION_PROGRESS"
const val PREFERENCE_INSTALLATION_DEVICE_OWNER = "PREFERENCE_INSTALLATION_DEVICE_OWNER"
const val PREFERENCE_PROXY_URL = "PREFERENCE_PROXY_URL"
@@ -74,6 +76,11 @@ object Preferences {
const val PREFERENCE_MIGRATION_VERSION = "PREFERENCE_MIGRATION_VERSION"
const val PREFERENCE_SELF_UPDATE_ENABLED = "PREFERENCE_SELF_UPDATE_ENABLED"
const val PREFERENCE_APP_LOCK_ENABLED = "PREFERENCE_APP_LOCK_ENABLED"
const val PREFERENCE_APP_LOCK_TIMEOUT = "PREFERENCE_APP_LOCK_TIMEOUT"
private var prefs: SharedPreferences? = null
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {

View File

@@ -87,7 +87,12 @@ class AuthViewModel @Inject constructor(
)
} catch (exception: Exception) {
Log.e(TAG, "Failed to generate Session", exception)
_authState.value = AuthState.Failed(exception.message.toString())
val message = when (exception) {
is UnknownHostException -> context.getString(R.string.check_connectivity)
else -> exception.message.toString()
}
_authState.value = AuthState.Failed(message)
}
}
}

View File

@@ -314,7 +314,8 @@ class AppDetailsViewModel @Inject constructor(
DownloadStatus.VERIFYING -> AppState.Verifying
DownloadStatus.COMPLETED -> if (isInstalled) defaultAppState else AppState.Installing(0F)
DownloadStatus.COMPLETED,
DownloadStatus.INSTALLING -> if (isInstalled) defaultAppState else AppState.Installing(0F)
else -> defaultAppState
}

View File

@@ -75,7 +75,7 @@ class DownloadsViewModel @Inject constructor(
fun install(download: Download) {
try {
appInstaller.getPreferredInstaller().install(download)
appInstaller.getPreferredInstaller(notifyOnFallback = true).install(download)
} catch (exception: Exception) {
Log.e(TAG, "Failed to install ${download.packageName}", exception)
}

View File

@@ -213,7 +213,9 @@ class MicroGViewModel @Inject constructor(
download == null -> InstallStatus.PENDING
download.status == DownloadStatus.FAILED -> InstallStatus.FAILED
download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING
download.status == DownloadStatus.COMPLETED -> InstallStatus.INSTALLING
download.status == DownloadStatus.INSTALLED -> InstallStatus.INSTALLED
download.status == DownloadStatus.COMPLETED ||
download.status == DownloadStatus.INSTALLING -> InstallStatus.INSTALLING
else -> InstallStatus.DOWNLOADING
}

View File

@@ -0,0 +1,24 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M240,880q-33,0 -56.5,-23.5T160,800v-400q0,-33 23.5,-56.5T240,320h40v-80q0,-83 58.5,-141.5T480,40q83,0 141.5,58.5T680,240v80h40q33,0 56.5,23.5T800,400v400q0,33 -23.5,56.5T720,880L240,880ZM240,800h480v-400L240,400v400ZM536.5,656.5Q560,633 560,600t-23.5,-56.5Q513,520 480,520t-56.5,23.5Q400,567 400,600t23.5,56.5Q447,680 480,680t56.5,-23.5ZM360,320h240v-80q0,-50 -35,-85t-85,-35q-50,0 -85,35t-35,85v80ZM240,800v-400,400Z"
android:fillColor="#e3e3e3"/>
</vector>

View File

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

View File

@@ -149,6 +149,7 @@
<string name="download_eta_min">%1$dm %2$ds left</string>
<string name="download_eta_sec">%1$ds left</string>
<string name="download_failed">"Download failed"</string>
<string name="download_failed_storage">Not enough storage space to download this app</string>
<string name="download_force_clear_all">"Force clear all"</string>
<string name="download_metadata">"Getting metadata"</string>
<string name="download_none">"No downloads"</string>
@@ -177,6 +178,17 @@
<string name="installer_am_unavailable">Install App Manager or change the installer.</string>
<string name="installer_root_unavailable">No root access. Grant it or change the installer.</string>
<string name="installer_shizuku_unavailable">Shizuku is not installed or set up properly.</string>
<string name="installer_fallback_session">Chosen installer is unavailable, using the default installer instead.</string>
<!-- Security -->
<string name="title_security">Security</string>
<string name="app_lock_title">App lock</string>
<string name="app_lock_summary">Require biometric or screen-lock authentication to open Aurora Store.</string>
<string name="app_lock_no_credential">Set up a screen lock (PIN, pattern, password or biometric) on your device first.</string>
<string name="app_lock_locked_message">Authentication is required to access Aurora Store</string>
<string name="app_lock_unlock">Unlock</string>
<string name="app_lock_prompt_title">Unlock Aurora Store</string>
<string name="app_lock_prompt_subtitle">Enter phone screen lock pattern, PIN, password or fingerprint</string>
<string name="installer_service_available">"Aurora Services is available and ready to install."</string>
<string name="installer_service_unavailable">Install Aurora Services 1.0.9 or above, or change the installer.</string>
<string name="installer_service_misconfigured">Set up Aurora Services and grant all permissions first.</string>
@@ -199,7 +211,9 @@
<string name="device_miui_extra">"Optionally you can choose Native installer, but then you can not install bundled (split) APKs, so choice is yours."</string>
<string name="dialog_title_self_update">"New update available"</string>
<string name="dialog_desc_native_split">"You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root."</string>
<string name="notification_channel_account">Account-related notification</string>
<string name="notification_channel_alerts">Errors &amp; alerts</string>
<string name="notification_group_activity">Downloads &amp; updates</string>
<string name="notification_group_alerts">Alerts</string>
<string name="notification_channel_export">App export notification</string>
<string name="notification_channel_install">Install notification</string>
<string name="notification_channel_downloads">Downloads notification</string>
@@ -228,6 +242,8 @@
<string name="pref_ui_similar_apps">"Similar and related apps"</string>
<string name="pref_updates_incompatible">"Show updates that may fail"</string>
<string name="pref_updates_incompatible_desc">"Display updates for incompatible or disabled apps that may fail to install."</string>
<string name="pref_self_update">"Self-update"</string>
<string name="pref_self_update_desc">"Offer new Aurora Store builds in the Updates tab."</string>
<string name="pref_aurora_only">"Filter apps from other sources."</string>
<string name="pref_aurora_only_desc">"Do not check for updates for apps installed from sources outside Aurora Store"</string>
<string name="pref_source_filters_title">"Filter apps from other sources"</string>
@@ -284,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>
@@ -317,6 +340,8 @@
<string name="app_updater_service_notif_text">Enables background app download</string>
<string name="update_available">update available</string>
<string name="updates_available">updates available</string>
<string name="updates_self_header">Self-update</string>
<string name="updates_self_desc">A newer build of Aurora Store is available</string>
<string name="updates_incompatible_header">Incompatible updates</string>
<string name="updates_incompatible_desc">These system app updates cannot be installed on this OS</string>
<string name="updates_approval_header">Updates requiring approval</string>
@@ -516,6 +541,8 @@
<string name="status_queued">Queued</string>
<string name="status_unavailable">Unavailable</string>
<string name="status_verifying">Verifying</string>
<string name="status_installing">Installing</string>
<string name="status_installed">Installed</string>
<!-- UnarchivePackageReceiver -->
<string name="authentication_required_title">Authentication required</string>
@@ -558,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>

View File

@@ -11,6 +11,7 @@ agCoreservice = "13.3.1.300"
androidGradlePlugin = "9.2.1"
androidx-hilt = "1.3.0"
androidx-junit = "1.3.0"
biometric = "1.1.0"
browser = "1.10.0"
coil = "3.4.0"
composeBom = "2026.05.01"
@@ -48,6 +49,7 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
androidx-adaptive-core = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" }
androidx-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" }
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }