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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.flowOn
fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo> = flow { fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo> = flow {
var bytesCopied: Long = 0 var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
var lastTotalBytesRead: Long = 0 var lastTotalBytesRead: Long = 0
var speed: Long = 0 var speed: Long = 0
@@ -23,6 +22,10 @@ fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo>
lastTotalBytesRead = totalBytesRead 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) { while (bytes >= 0) {
out.write(buffer, 0, bytes) out.write(buffer, 0, bytes)
out.flush() out.flush()
@@ -32,5 +35,7 @@ fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo>
emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed)) emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed))
bytes = read(buffer) bytes = read(buffer)
} }
} finally {
timer.cancel() timer.cancel()
}
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)

View File

@@ -8,33 +8,51 @@ package com.aurora.store
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.getPackageName import com.aurora.extensions.getPackageName
import com.aurora.store.R
import com.aurora.store.compose.composition.LocalNetworkStatus import com.aurora.store.compose.composition.LocalNetworkStatus
import com.aurora.store.compose.composition.LocalUI import com.aurora.store.compose.composition.LocalUI
import com.aurora.store.compose.composition.UI import com.aurora.store.compose.composition.UI
import com.aurora.store.compose.navigation.NavDisplay import com.aurora.store.compose.navigation.NavDisplay
import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.theme.AuroraTheme 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.model.NetworkStatus
import com.aurora.store.data.providers.NetworkProvider import com.aurora.store.data.providers.NetworkProvider
import com.aurora.store.data.receiver.MigrationReceiver import com.aurora.store.data.receiver.MigrationReceiver
import com.aurora.store.util.AppLockAuthenticator
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ComposeActivity : ComponentActivity() { class ComposeActivity : FragmentActivity() {
@Inject lateinit var networkProvider: NetworkProvider @Inject lateinit var networkProvider: NetworkProvider
@Inject lateinit var appLockManager: AppLockManager
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
MigrationReceiver.runMigrationsIfRequired(this) MigrationReceiver.runMigrationsIfRequired(this)
enableEdgeToEdge() enableEdgeToEdge()
@@ -53,16 +71,85 @@ class ComposeActivity : ComponentActivity() {
val networkStatus by networkProvider.status.collectAsStateWithLifecycle( val networkStatus by networkProvider.status.collectAsStateWithLifecycle(
initialValue = NetworkStatus.AVAILABLE 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, LocalUI provides localUI,
LocalNetworkStatus provides networkStatus LocalNetworkStatus provides networkStatus
) { ) {
AuroraTheme {
NavDisplay(startDestination = startDestination) 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 { private fun resolveStartDestination(): Screen {
// Parcel-based navigation (e.g. from NotificationUtil) // Parcel-based navigation (e.g. from NotificationUtil)
@@ -91,4 +178,6 @@ class ComposeActivity : ComponentActivity() {
!Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding
else -> Screen.Splash() 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) { trailing = when (download.status) {
DownloadStatus.COMPLETED -> { DownloadStatus.COMPLETED, DownloadStatus.INSTALLED -> {
{ {
Icon( Icon(
painter = painterResource(R.drawable.ic_check), painter = painterResource(R.drawable.ic_check),

View File

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

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.installed.InstalledScreen
import com.aurora.store.compose.ui.main.MainScreen import com.aurora.store.compose.ui.main.MainScreen
import com.aurora.store.compose.ui.onboarding.OnboardingScreen import com.aurora.store.compose.ui.onboarding.OnboardingScreen
import com.aurora.store.compose.ui.preferences.NotificationPreferenceScreen
import com.aurora.store.compose.ui.preferences.SettingsScreen import com.aurora.store.compose.ui.preferences.SettingsScreen
import com.aurora.store.compose.ui.preferences.UIPreferenceScreen import com.aurora.store.compose.ui.preferences.UIPreferenceScreen
import com.aurora.store.compose.ui.preferences.installation.InstallationPreferenceScreen import com.aurora.store.compose.ui.preferences.installation.InstallationPreferenceScreen
import com.aurora.store.compose.ui.preferences.installation.InstallerScreen 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.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.SourceFiltersScreen
import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen
import com.aurora.store.compose.ui.search.SearchScreen import com.aurora.store.compose.ui.search.SearchScreen
@@ -160,8 +162,10 @@ fun NavDisplay(startDestination: NavKey) {
Destination.NetworkPreference -> backstack.add(Screen.NetworkPreference) Destination.NetworkPreference -> backstack.add(Screen.NetworkPreference)
Destination.Dispenser -> backstack.add(Screen.Dispenser) Destination.Dispenser -> backstack.add(Screen.Dispenser)
Destination.UIPreference -> backstack.add(Screen.UIPreference) Destination.UIPreference -> backstack.add(Screen.UIPreference)
Destination.NotificationPreference -> backstack.add(Screen.NotificationPreference)
Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference) Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference)
Destination.SourceFilters -> backstack.add(Screen.SourceFilters) Destination.SourceFilters -> backstack.add(Screen.SourceFilters)
Destination.SecurityPreference -> backstack.add(Screen.SecurityPreference)
} }
} }
@@ -282,8 +286,10 @@ fun NavDisplay(startDestination: NavKey) {
entry<Screen.Settings> { SettingsScreen(onNavigateTo = ::navigate) } entry<Screen.Settings> { SettingsScreen(onNavigateTo = ::navigate) }
entry<Screen.NetworkPreference> { NetworkPreferenceScreen(onNavigateTo = ::navigate) } entry<Screen.NetworkPreference> { NetworkPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.UIPreference> { UIPreferenceScreen() } entry<Screen.UIPreference> { UIPreferenceScreen() }
entry<Screen.NotificationPreference> { NotificationPreferenceScreen() }
entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) } entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.SourceFilters> { SourceFiltersScreen() } entry<Screen.SourceFilters> { SourceFiltersScreen() }
entry<Screen.SecurityPreference> { SecurityPreferenceScreen() }
} }
) )
} }

View File

@@ -89,12 +89,18 @@ sealed class Screen : NavKey, Parcelable {
@Serializable @Serializable
data object UIPreference : Screen() data object UIPreference : Screen()
@Serializable
data object NotificationPreference : Screen()
@Serializable @Serializable
data object UpdatesPreference : Screen() data object UpdatesPreference : Screen()
@Serializable @Serializable
data object SourceFilters : Screen() data object SourceFilters : Screen()
@Serializable
data object SecurityPreference : Screen()
@Serializable @Serializable
data class Splash(val packageName: String? = null) : Screen() 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)) } headlineContent = { Text(stringResource(R.string.pref_ui_title)) }
) )
} }
item {
ListItem(
modifier = Modifier.clickable {
onNavigateTo(Destination.NotificationPreference)
},
leadingContent = {
Icon(
painter = painterResource(R.drawable.ic_notification_settings),
contentDescription = null
)
},
headlineContent = { Text(stringResource(R.string.title_notifications)) }
)
}
item { item {
ListItem( ListItem(
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) }, modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },
@@ -112,6 +126,18 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) {
headlineContent = { Text(stringResource(R.string.title_updates)) } 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.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog
import com.aurora.store.data.model.UpdateMode 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
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE 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_AURORA_ONLY
import com.aurora.store.util.Preferences.PREFERENCE_FILTER_FDROID 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_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_AUTO
import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_CHECK_INTERVAL import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_CHECK_INTERVAL
import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED
@@ -79,6 +81,7 @@ fun UpdatesPreferenceScreen(
onScheduleAutomatedCheck = { viewModel.updateHelper.scheduleAutomatedCheck() }, onScheduleAutomatedCheck = { viewModel.updateHelper.scheduleAutomatedCheck() },
onUpdateAutomatedCheck = { viewModel.updateHelper.updateAutomatedCheck() }, onUpdateAutomatedCheck = { viewModel.updateHelper.updateAutomatedCheck() },
onCheckUpdatesNow = { viewModel.updateHelper.checkUpdatesNow() }, onCheckUpdatesNow = { viewModel.updateHelper.checkUpdatesNow() },
onDeleteSelfUpdate = { viewModel.updateHelper.deleteSelfUpdate() },
onNavigateTo = onNavigateTo onNavigateTo = onNavigateTo
) )
} }
@@ -89,6 +92,7 @@ private fun ScreenContent(
onScheduleAutomatedCheck: () -> Unit = {}, onScheduleAutomatedCheck: () -> Unit = {},
onUpdateAutomatedCheck: () -> Unit = {}, onUpdateAutomatedCheck: () -> Unit = {},
onCheckUpdatesNow: () -> Unit = {}, onCheckUpdatesNow: () -> Unit = {},
onDeleteSelfUpdate: () -> Unit = {},
onNavigateTo: (Destination) -> Unit = {} onNavigateTo: (Destination) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -109,6 +113,10 @@ private fun ScreenContent(
var updatesExtended by remember { var updatesExtended by remember {
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_UPDATES_EXTENDED)) 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. // Re-read source-filter prefs after returning from SourceFiltersScreen.
val lifecycleOwner = LocalLifecycleOwner.current 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 groupedUpdates = remember(updateMap) {
val self = mutableListOf<Map.Entry<Update, Download?>>()
val main = mutableListOf<Map.Entry<Update, Download?>>() val main = mutableListOf<Map.Entry<Update, Download?>>()
val approval = mutableListOf<Map.Entry<Update, Download?>>() val approval = mutableListOf<Map.Entry<Update, Download?>>()
val incompatible = mutableListOf<Map.Entry<Update, Download?>>() val incompatible = mutableListOf<Map.Entry<Update, Download?>>()
updateMap?.entries?.forEach { entry -> updateMap?.entries?.forEach { entry ->
when { when {
entry.key.isSelfUpdate(context) -> self += entry
entry.key.isIncompatible -> incompatible += entry entry.key.isIncompatible -> incompatible += entry
entry.key.requiresOwnershipTransfer(context) -> approval += entry entry.key.requiresOwnershipTransfer(context) -> approval += entry
else -> main += 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 mainAnyActive = mainEntries.any { it.value.isActive() }
val approvalAnyActive = approvalEntries.any { it.value.isActive() } val approvalAnyActive = approvalEntries.any { it.value.isActive() }
@@ -110,6 +112,29 @@ fun UpdatesScreen(
dimensionResource(R.dimen.spacing_medium) 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()) { if (mainEntries.isNotEmpty()) {
item(key = "header_main") { item(key = "header_main") {
val title = "${mainEntries.size} " + stringResource( 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.content.Context
import android.util.Log import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkRequest
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.store.AuroraApp 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.model.DownloadStatus
import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadDao 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.data.work.DownloadWorker
import com.aurora.store.util.PathUtil import com.aurora.store.util.PathUtil
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -32,7 +39,8 @@ import kotlinx.coroutines.launch
*/ */
class DownloadHelper @Inject constructor( class DownloadHelper @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val downloadDao: DownloadDao private val downloadDao: DownloadDao,
private val appInstaller: AppInstaller
) { ) {
companion object { companion object {
@@ -68,13 +76,50 @@ class DownloadHelper @Inject constructor(
cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList()) cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList())
}.invokeOnCompletion { }.invokeOnCompletion {
observeDownloads() 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() { private fun observeDownloads() {
downloadDao.downloads().onEach { list -> downloadDao.downloads().onEach { list ->
try { 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 } list.find { it.status == DownloadStatus.QUEUED }
?.let { queuedDownload -> ?.let { queuedDownload ->
Log.i(TAG, "Enqueued download worker for ${queuedDownload.packageName}") Log.i(TAG, "Enqueued download worker for ${queuedDownload.packageName}")
@@ -92,7 +137,7 @@ class DownloadHelper @Inject constructor(
* @param app [App] to download * @param app [App] to download
*/ */
suspend fun enqueueApp(app: App) { 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 * @param update [Update] to download
*/ */
suspend fun enqueueUpdate(update: Update) { 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 * @param externalApk [ExternalApk] to download
*/ */
suspend fun enqueueStandalone(externalApk: ExternalApk) { 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) { suspend fun cancelDownload(packageName: String) {
Log.i(TAG, "Cancelling download for $packageName") Log.i(TAG, "Cancelling download for $packageName")
WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$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) downloadDao.updateStatus(packageName, DownloadStatus.CANCELLED)
} }
@@ -197,11 +294,24 @@ class DownloadHelper @Inject constructor(
.putString(PACKAGE_NAME, download.packageName) .putString(PACKAGE_NAME, download.packageName)
.build() .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>() val work = OneTimeWorkRequestBuilder<DownloadWorker>()
.addTag(DOWNLOAD_WORKER) .addTag(DOWNLOAD_WORKER)
.addTag("$PACKAGE_NAME:${download.packageName}") .addTag("$PACKAGE_NAME:${download.packageName}")
.addTag("$VERSION_CODE:${download.versionCode}") .addTag("$VERSION_CODE:${download.versionCode}")
.addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP) .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) .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.setInputData(inputData) .setInputData(inputData)
.build() .build()

View File

@@ -14,8 +14,10 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.BuildConfig
import com.aurora.store.data.event.BusEvent import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.event.InstallerEvent 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.model.UpdateMode
import com.aurora.store.data.room.update.IgnoredUpdate import com.aurora.store.data.room.update.IgnoredUpdate
import com.aurora.store.data.room.update.IgnoredUpdateDao import com.aurora.store.data.room.update.IgnoredUpdateDao
@@ -185,6 +187,14 @@ class UpdateHelper @Inject constructor(
updateDao.deleteAll() 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. * Hide all future updates for [packageName] until [unignore] is called.
*/ */
@@ -243,6 +253,13 @@ class UpdateHelper @Inject constructor(
private suspend fun deleteInvalidUpdates() { private suspend fun deleteInvalidUpdates() {
updateDao.updates().firstOrNull()?.forEach { update -> 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)) { if (!update.isInstalled(context) || update.isUpToDate(context)) {
deleteUpdate(update.packageName) deleteUpdate(update.packageName)
} }

View File

@@ -25,13 +25,17 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import com.aurora.Constants.PACKAGE_NAME_GMS import com.aurora.Constants.PACKAGE_NAME_GMS
import com.aurora.extensions.TAG
import com.aurora.extensions.getUpdateOwnerPackageNameCompat import com.aurora.extensions.getUpdateOwnerPackageNameCompat
import com.aurora.extensions.isOAndAbove import com.aurora.extensions.isOAndAbove
import com.aurora.extensions.isPAndAbove import com.aurora.extensions.isPAndAbove
import com.aurora.extensions.isSAndAbove import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.toast
import com.aurora.store.BuildConfig 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.ShizukuInstaller.Companion.SHIZUKU_PACKAGE_NAME
import com.aurora.store.data.installer.base.IInstaller import com.aurora.store.data.installer.base.IInstaller
import com.aurora.store.data.model.Installer import com.aurora.store.data.model.Installer
@@ -172,8 +176,15 @@ class AppInstaller @Inject constructor(
fun hasShizukuOrSui(context: Context): Boolean = isOAndAbove && fun hasShizukuOrSui(context: Context): Boolean = isOAndAbove &&
(PackageUtil.isInstalled(context, SHIZUKU_PACKAGE_NAME) || Sui.isSui()) (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 Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
} catch (_: Exception) {
false
}
fun uninstall(context: Context, packageName: String) { fun uninstall(context: Context, packageName: String) {
val intent = Intent().apply { val intent = Intent().apply {
@@ -196,7 +207,15 @@ class AppInstaller @Inject constructor(
fun getMicroGInstaller(): IInstaller = microGInstaller 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.SESSION -> sessionInstaller
Installer.NATIVE -> nativeInstaller Installer.NATIVE -> nativeInstaller
@@ -223,4 +242,12 @@ class AppInstaller @Inject constructor(
defaultInstaller 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( private fun stageInstall(
packageName: String, packageName: String,
versionCode: Long, versionCode: Long,
@@ -189,7 +199,12 @@ class SessionInstaller @Inject constructor(
): Int? { ): Int? {
val resolvedPackageName = sharedLibPkgName.ifBlank { packageName } 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 sessionId = packageInstaller.createSession(sessionParams)
val session = packageInstaller.openSession(sessionId) 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 { SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(packageName) setAppPackageName(packageName)
if (totalSize > 0) setSize(totalSize)
setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO)
if (isNAndAbove) { if (isNAndAbove) {
setOriginatingUid(Process.myUid()) setOriginatingUid(Process.myUid())

View File

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

View File

@@ -11,10 +11,20 @@ enum class DownloadStatus(@StringRes val localized: Int) {
QUEUED(R.string.status_queued), QUEUED(R.string.status_queued),
UNAVAILABLE(R.string.status_unavailable), UNAVAILABLE(R.string.status_unavailable),
VERIFYING(R.string.status_verifying), 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 { companion object {
val finished = listOf(FAILED, CANCELLED, COMPLETED) val finished = setOf(FAILED, CANCELLED, COMPLETED, INSTALLED)
val running = listOf(QUEUED, PURCHASING, DOWNLOADING) 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 * SPDX-FileCopyrightText: 2026 Aurora OSS
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com> * SPDX-License-Identifier: GPL-3.0-or-later
*
* 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/>.
*
*/ */
package com.aurora.store.data.model 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.Artwork
import com.aurora.gplayapi.data.models.EncodedCertificateSet import com.aurora.gplayapi.data.models.EncodedCertificateSet
import com.aurora.gplayapi.data.models.PlayFile import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.BuildConfig
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.util.CertUtil import com.aurora.store.util.CertUtil
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
data class SelfUpdate( data class SelfUpdate(
@SerialName("version_name") var versionName: String = String(), @SerialName("version_name")
@SerialName("version_code") var versionCode: Long = 0, val versionName: String = "",
@SerialName("aurora_build") var auroraBuild: String = String(), @SerialName("version_code")
@SerialName("fdroid_build") var fdroidBuild: String = String(), val versionCodeRaw: String = "0",
@SerialName("updated_on") var updatedOn: String = String(), @SerialName("download_url")
val changelog: String = String(), val downloadUrl: String = "",
val size: Long = 0L, val changelog: String = "",
val timestamp: Long = 0L @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 { val versionCode: Long get() = versionCodeRaw.toLongOrNull() ?: 0L
private const val BASE_URL = "https://gitlab.com/AuroraOSS/AuroraStore/-/raw/master" 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 * Maps the feed entry onto a regular [App] so it can flow through the normal
val icon = "fastlane/metadata/android/en-US/images/icon.png" * update pipeline ([com.aurora.store.data.room.update.Update.fromApp] →
* download → install). The certificate set is the currently installed app's own
val downloadURL = if (CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID)) { * hashes so [com.aurora.store.data.room.update.Update.hasValidCert] holds and the
selfUpdate.fdroidBuild * update is never filtered out as untrusted.
} else { */
selfUpdate.auroraBuild fun toApp(context: Context): App = App(
}
return App(
packageName = context.packageName, packageName = context.packageName,
versionCode = selfUpdate.versionCode, versionCode = versionCode,
versionName = selfUpdate.versionName, versionName = versionName,
changes = selfUpdate.changelog, changes = changelog,
size = selfUpdate.size, size = size,
updatedOn = selfUpdate.updatedOn, updatedOn = updatedOn,
displayName = context.getString(R.string.app_name), displayName = context.getString(R.string.app_name),
developerName = "Rahul Kumar Patel", developerName = "Rahul Kumar Patel",
iconArtwork = Artwork(url = "$BASE_URL/$icon"), iconArtwork = Artwork(url = ICON_URL),
fileList = mutableListOf( fileList = mutableListOf(
PlayFile( PlayFile(
name = "${context.packageName}.apk", name = "${context.packageName}.apk",
url = downloadURL, url = downloadUrl,
size = selfUpdate.size size = size
) )
), ),
isFree = true, isFree = true,
@@ -80,6 +78,11 @@ data class SelfUpdate(
EncodedCertificateSet(certificateSet = it, sha256 = String()) EncodedCertificateSet(certificateSet = it, sha256 = String())
}.toMutableList() }.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 package com.aurora.store.data.receiver
import android.app.NotificationManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -26,7 +25,6 @@ import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
import android.util.Log import android.util.Log
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.content.getSystemService
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.extensions.runOnUiThread import com.aurora.extensions.runOnUiThread
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
@@ -92,14 +90,12 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() {
displayName: String, displayName: String,
status: Int status: Int
) { ) {
val notificationManager = context.getSystemService<NotificationManager>() NotificationUtil.notifyInstallFailed(
val notification = NotificationUtil.getInstallerStatusNotification(
context, context,
packageName, packageName,
displayName, displayName,
InstallerBase.getErrorString(context, status) InstallerBase.getErrorString(context, status)
) )
notificationManager?.notify(packageName.hashCode(), notification)
} }
internal fun promptUser(context: Context, intent: Intent) { internal fun promptUser(context: Context, intent: Intent) {

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 val isRunning get() = status in DownloadStatus.running
private val isSuccessful get() = status == DownloadStatus.COMPLETED 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 { companion object {
fun fromApp(app: App): Download = Download( fun fromApp(app: App): Download = Download(
app.packageName, app.packageName,
@@ -108,7 +115,10 @@ data class Download(
} }
fun canInstall(context: Context): Boolean { fun canInstall(context: Context): Boolean {
if (!isSuccessful) return false
val dir = PathUtil.getAppDownloadDir(context, packageName, versionCode) 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.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters 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 com.aurora.store.util.PathUtil
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -17,12 +19,14 @@ import java.util.concurrent.TimeUnit.HOURS
import java.util.concurrent.TimeUnit.MINUTES import java.util.concurrent.TimeUnit.MINUTES
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.toDuration import kotlin.time.toDuration
import kotlinx.coroutines.flow.first
/** /**
* A periodic worker to automatically clear the old downloads cache periodically. * A periodic worker to automatically clear the old downloads cache periodically.
*/ */
@HiltWorker @HiltWorker
class CacheWorker @AssistedInject constructor( class CacheWorker @AssistedInject constructor(
private val downloadDao: DownloadDao,
@Assisted private val context: Context, @Assisted private val context: Context,
@Assisted workerParams: WorkerParameters @Assisted workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
@@ -58,6 +62,17 @@ class CacheWorker @AssistedInject constructor(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Log.i(TAG, "Cleaning cache") 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 -> PathUtil.getOldDownloadDirectories(context).filter { it.exists() }.forEach { dir ->
// Downloads // Downloads
Log.i(TAG, "Deleting old unused download directory: $dir") Log.i(TAG, "Deleting old unused download directory: $dir")
@@ -75,10 +90,14 @@ class CacheWorker @AssistedInject constructor(
download.listFiles()!!.forEach { versionCode -> download.listFiles()!!.forEach { versionCode ->
// 20240325 // 20240325
val isProtected = (download.name to versionCode.name.toLongOrNull()) in
protectedVersions
if (versionCode.listFiles().isNullOrEmpty()) { if (versionCode.listFiles().isNullOrEmpty()) {
// Purge empty non-accessible directory // Purge empty non-accessible directory
Log.i(TAG, "Removing empty directory for ${download.name}, ${versionCode.name}") Log.i(TAG, "Removing empty directory for ${download.name}, ${versionCode.name}")
versionCode.deleteRecursively() versionCode.deleteRecursively()
} else if (isProtected) {
Log.i(TAG, "Keeping ${download.name} (${versionCode.name}); install pending")
} else { } else {
versionCode.deleteIfOld() versionCode.deleteIfOld()
} }

View File

@@ -11,6 +11,7 @@ import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.storage.StorageManager
import android.util.Log import android.util.Log
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.scale import androidx.core.graphics.scale
@@ -21,6 +22,7 @@ import androidx.work.WorkInfo.Companion.STOP_REASON_USER
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.extensions.copyTo import com.aurora.extensions.copyTo
import com.aurora.extensions.isOAndAbove
import com.aurora.extensions.isPAndAbove import com.aurora.extensions.isPAndAbove
import com.aurora.extensions.isQAndAbove import com.aurora.extensions.isQAndAbove
import com.aurora.extensions.isSAndAbove 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.NotificationUtil
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
import com.aurora.store.util.PathUtil import com.aurora.store.util.PathUtil
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_NOTIFICATION_PROGRESS
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import java.io.File import java.io.File
import java.io.FileOutputStream 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.SocketException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
@@ -77,17 +85,31 @@ class DownloadWorker @AssistedInject constructor(
companion object { companion object {
private const val NOTIFICATION_ID: Int = 200 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 lateinit var download: Download
private val notificationManager = context.getSystemService<NotificationManager>()!! private val notificationManager = context.getSystemService<NotificationManager>()!!
// When the user opts out of progress notifications, the mandatory foreground notification
// is kept minimal and per-tick progress refreshes are skipped. Read live so toggling the
// setting takes effect on the next download.
private val showProgress: Boolean
get() = Preferences.getBoolean(context, PREFERENCE_NOTIFICATION_PROGRESS, true)
private var icon: Bitmap? = null private var icon: Bitmap? = null
private var totalBytes by Delegates.notNull<Long>() private var totalBytes by Delegates.notNull<Long>()
private var totalProgress = 0 private var totalProgress = 0
private var downloadedBytes = 0L 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 NoNetworkException : Exception(context.getString(R.string.title_no_network))
inner class NothingToDownloadException : Exception(context.getString(R.string.purchase_no_file)) inner class NothingToDownloadException : Exception(context.getString(R.string.purchase_no_file))
inner class DownloadFailedException : Exception(context.getString(R.string.download_failed)) inner class DownloadFailedException : Exception(context.getString(R.string.download_failed))
@@ -97,6 +119,11 @@ class DownloadWorker @AssistedInject constructor(
inner class VerificationFailedException : inner class VerificationFailedException :
Exception(context.getString(R.string.verification_failed)) 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 { override suspend fun doWork(): Result {
super.doWork() super.doWork()
@@ -173,6 +200,15 @@ class DownloadWorker @AssistedInject constructor(
downloadDao.updateFiles(download.packageName, download.fileList) downloadDao.updateFiles(download.packageName, download.fileList)
downloadDao.updateSharedLibs(download.packageName, download.sharedLibs) 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 // Download files
try { try {
for (file in files) { for (file in files) {
@@ -184,24 +220,34 @@ class DownloadWorker @AssistedInject constructor(
download.downloadedFiles++ download.downloadedFiles++
} }
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is DownloadCancelledException) { // Only purge partial files on a genuine user/app cancellation. A stop caused
Log.i(TAG, "Download cancelled for ${download.packageName}") // by lost connectivity, quota or device-state must keep the partials so the
// Try to delete all downloaded files // 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) } } runCatching { files.forEach { deleteFile(it) } }
} }
return onFailure(exception) return onFailure(exception)
} }
// Report failure if download was stopped or failed // A stop that isn't a user cancellation (e.g. connectivity constraint) should be
if (isStopped) return onFailure(DownloadFailedException()) // 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 { try {
notifyStatus(DownloadStatus.VERIFYING) 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) { } catch (exception: Exception) {
Log.e(TAG, "Failed to verify ${download.packageName}", 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()) return onFailure(VerificationFailedException())
} }
@@ -214,7 +260,11 @@ class DownloadWorker @AssistedInject constructor(
private suspend fun onSuccess(): Result { private suspend fun onSuccess(): Result {
return withContext(NonCancellable) { return withContext(NonCancellable) {
return@withContext try { return@withContext try {
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() Result.success()
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to install ${download.packageName}", 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 { private suspend fun onFailure(exception: Exception): Result {
return withContext(NonCancellable) { return withContext(NonCancellable) {
Log.i(TAG, "Job failed: ${download.packageName}", exception) Log.i(TAG, "Job failed: ${download.packageName}", exception)
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP) val cancelledByUser = isCancelledByUser()
if (isSAndAbove && stopReason in cancelReasons) {
// 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) notifyStatus(DownloadStatus.CANCELLED)
} else { } else {
when (exception) { when (exception) {
@@ -297,28 +395,63 @@ class DownloadWorker @AssistedInject constructor(
if (file.exists() && verifyFile(gFile)) { if (file.exists() && verifyFile(gFile)) {
Log.i(TAG, "$file is already downloaded!") Log.i(TAG, "$file is already downloaded!")
downloadedBytes += file.length() downloadedBytes += file.length()
verifiedFiles.add(file.absolutePath)
return@withContext true return@withContext true
} }
try { try {
val tmpFileSuffix = ".tmp"
val tmpFile = File(file.absolutePath + tmpFileSuffix)
// Download as a temporary file to avoid installing corrupted files // 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 okHttpClient = httpClient as HttpClient
val headers = mutableMapOf<String, String>() val headers = mutableMapOf<String, String>()
if (existingBytes > 0) {
if (!isNewFile) { Log.i(TAG, "$tmpFile has an unfinished download, requesting resume!")
Log.i(TAG, "$tmpFile has an unfinished download, resuming!") headers["Range"] = "bytes=$existingBytes-"
downloadedBytes += tmpFile.length()
headers["Range"] = "bytes=${tmpFile.length()}-"
} }
okHttpClient.call(gFile.url, headers).body.byteStream().use { input -> val response = okHttpClient.call(gFile.url, headers)
FileOutputStream(tmpFile, !isNewFile).use { if (!response.isSuccessful) {
input.copyTo(it, gFile.size).collect { info -> onProgress(info) } 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 { override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = if (this::download.isInitialized) { val notification = if (this::download.isInitialized && showProgress) {
NotificationUtil.getDownloadNotification(context, download, icon) NotificationUtil.getDownloadNotification(context, download, icon)
} else { } else {
NotificationUtil.getDownloadNotification(context) NotificationUtil.getDownloadNotification(context)
@@ -416,18 +549,44 @@ class DownloadWorker @AssistedInject constructor(
downloadDao.updateStatus(download.packageName, status) downloadDao.updateStatus(download.packageName, status)
when (status) { when (status) {
// Internal phases the user doesn't need a separate notification for: the ongoing
// foreground progress notification already conveys that work is in progress.
// Clear any stale per-app notification (e.g. a prior failure being retried) so it
// doesn't linger.
DownloadStatus.PURCHASING,
DownloadStatus.VERIFYING, DownloadStatus.VERIFYING,
DownloadStatus.CANCELLED -> return DownloadStatus.CANCELLED -> {
notificationManager.cancel(download.packageName.hashCode())
return
}
DownloadStatus.COMPLETED -> { DownloadStatus.COMPLETED -> {
// Mark progress as 100 manually to avoid race conditions // Mark progress as 100 manually to avoid race conditions
download.progress = 100 download.progress = 100
downloadDao.updateProgress(download.packageName, 100, 0, 0) downloadDao.updateProgress(download.packageName, 100, 0, 0)
// Silently-installable apps install automatically and get a single
// "installed" notification afterwards, so a separate "download complete"
// notice is just noise. Only surface completion when the user must act on it
// (tap to install).
val needsUserAction = !AppInstaller.canInstallSilently(
context,
download.packageName,
download.targetSdk
)
if (!needsUserAction) {
notificationManager.cancel(download.packageName.hashCode())
return
}
} }
else -> {} else -> {}
} }
// Skip detailed progress refreshes when the user has hidden progress; the minimal
// foreground notification posted via getForegroundInfo keeps the download alive.
if (isProgress && !showProgress) return
val notification = NotificationUtil.getDownloadNotification( val notification = NotificationUtil.getDownloadNotification(
context, context,
download, download,
@@ -438,6 +597,12 @@ class DownloadWorker @AssistedInject constructor(
if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(), if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(),
notification notification
) )
// A failed download is a grouped child; reconcile the failure summary so a bulk
// update collapses into a single "N apps failed" entry.
if (status == DownloadStatus.FAILED) {
NotificationUtil.refreshGroupSummaries(context)
}
} }
/** /**
@@ -452,6 +617,10 @@ class DownloadWorker @AssistedInject constructor(
val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256 val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256
val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.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 if (expectedSha.isBlank()) return false
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
@@ -489,4 +658,44 @@ class DownloadWorker @AssistedInject constructor(
Log.i(TAG, "Deleted Temp: $tmpFile") 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.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract
import android.util.Log import android.util.Log
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aurora.extensions.isQAndAbove
import com.aurora.store.data.room.download.Download 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.NotificationUtil
import com.aurora.store.util.PathUtil import com.aurora.store.util.PathUtil
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import java.io.File import java.io.File
import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@@ -34,7 +40,8 @@ import java.util.zip.ZipOutputStream
@HiltWorker @HiltWorker
class ExportWorker @AssistedInject constructor( class ExportWorker @AssistedInject constructor(
@Assisted private val context: Context, @Assisted private val context: Context,
@Assisted workerParams: WorkerParameters @Assisted workerParams: WorkerParameters,
private val downloadDao: DownloadDao
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
companion object { companion object {
@@ -48,6 +55,9 @@ class ExportWorker @AssistedInject constructor(
private const val NOTIFICATION_ID = 500 private const val NOTIFICATION_ID = 500
private const val NOTIFICATION_ID_FGS = 501 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 * Exports the given download to the URI
* @param download Download to export * @param download Download to export
@@ -67,7 +77,12 @@ class ExportWorker @AssistedInject constructor(
.build() .build()
Log.i(TAG, "Exporting download for ${download.packageName}/${download.versionCode}") 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) { if (packageName.isNullOrEmpty() || versionCode == -1L) {
Log.e(TAG, "Input data is corrupt, bailing out!") Log.e(TAG, "Input data is corrupt, bailing out!")
notifyStatus(displayName ?: String(), uri, false) return fail(displayName ?: String(), uri)
return Result.failure()
} }
try { 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) notifyStatus(displayName ?: packageName, uri)
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to export $packageName", exception) Log.e(TAG, "Failed to export $packageName", exception)
notifyStatus(displayName ?: packageName, uri, false) return fail(displayName ?: packageName, uri)
return Result.failure()
} }
return Result.success() return Result.success()
} }
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo( override suspend fun getForegroundInfo(): ForegroundInfo =
NOTIFICATION_ID_FGS, exportForegroundInfo(inputData.getString(DISPLAY_NAME))
NotificationUtil.getExportNotification(context)
) 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) { private fun notifyStatus(packageName: String, uri: Uri, success: Boolean = true) {
notificationManager.notify( 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(), * Updates the ongoing foreground notification to reflect export [progress] (0-100).
uri */
) private suspend fun notifyProgress(displayName: String, progress: Int) {
setForeground(exportForegroundInfo(displayName, progress))
}
/** /**
* Bundles all the given APKs to a zip file * Bundles the given files into a zip written to the [uri], preserving each file's
* @param fileList List of APKs to add to the zip * relative path within the archive so shared libraries and OBB/patch files retain
* @param uri [Uri] of the file to write the APKs * 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) { private suspend fun bundleFiles(displayName: String, entries: Map<String, File>, uri: Uri) {
ZipOutputStream(context.contentResolver.openOutputStream(uri)).use { zipOutput -> val totalBytes = entries.values.sumOf { it.length() }.coerceAtLeast(1)
fileList.forEach { file -> var writtenBytes = 0L
file.inputStream().use { input -> var lastProgress = -1
val zipEntry = ZipEntry(file.name)
zipOutput.putNextEntry(zipEntry) val output = context.contentResolver.openOutputStream(uri)!!.buffered(BUFFER_SIZE)
input.copyTo(zipOutput) 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() zipOutput.closeEntry()
} }
} }

View File

@@ -15,7 +15,6 @@ import com.aurora.extensions.isHyperOS
import com.aurora.extensions.isIgnoringBatteryOptimizations import com.aurora.extensions.isIgnoringBatteryOptimizations
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.BuildConfig import com.aurora.store.BuildConfig
import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.helper.UpdateHelper 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.BuildType
import com.aurora.store.data.model.SelfUpdate import com.aurora.store.data.model.SelfUpdate
import com.aurora.store.data.model.UpdateMode 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.AccountProvider
import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.BlacklistProvider 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.NotificationUtil
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences 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 com.aurora.store.util.Preferences.PREFERENCE_UPDATES_AUTO
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -41,18 +42,22 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
/** /**
* A worker to check for updates for installed apps based on saved authentication data, * A worker that drives periodic app-update checks. The repeat interval is configurable
* filters and the auto-updates mode selected by the user. The repeat interval * by the user, defaulting to 3 hours with a flex time of 30 minutes.
* 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. * Avoid using this worker directly and prefer using [UpdateHelper] instead.
* @see AuthWorker * @see AuthWorker
*/ */
@HiltWorker @HiltWorker
class UpdateWorker @AssistedInject constructor( class UpdateWorker @AssistedInject constructor(
private val httpClient: HttpClient,
private val json: Json, private val json: Json,
private val blacklistProvider: BlacklistProvider, private val blacklistProvider: BlacklistProvider,
private val httpClient: IHttpClient,
private val updateDao: UpdateDao, private val updateDao: UpdateDao,
private val downloadHelper: DownloadHelper, private val downloadHelper: DownloadHelper,
private val authProvider: AuthProvider, private val authProvider: AuthProvider,
@@ -61,11 +66,18 @@ class UpdateWorker @AssistedInject constructor(
@Assisted workerParams: WorkerParameters @Assisted workerParams: WorkerParameters
) : AuthWorker(authProvider, context, workerParams) { ) : 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) && * `true` when the build supports self-update ([PackageUtil.isSelfUpdateSupported])
BuildType.CURRENT != BuildType.DEBUG * 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 private val isAuroraOnlyFilterEnabled: Boolean
get() = Preferences.getBoolean(context, Preferences.PREFERENCE_FILTER_AURORA_ONLY, false) get() = Preferences.getBoolean(context, Preferences.PREFERENCE_FILTER_AURORA_ONLY, false)
@@ -124,7 +136,8 @@ class UpdateWorker @AssistedInject constructor(
return Result.success() 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 val filteredUpdates = updates
.filter { it.hasValidCert } .filter { it.hasValidCert }
.filterNot { it.isSelfUpdate(context) } .filterNot { it.isSelfUpdate(context) }
@@ -152,7 +165,7 @@ class UpdateWorker @AssistedInject constructor(
} }
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo( override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
notificationID, NOTIFICATION_ID,
NotificationUtil.getUpdateNotification(context) NotificationUtil.getUpdateNotification(context)
) )
@@ -206,7 +219,18 @@ class UpdateWorker @AssistedInject constructor(
.filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) } .filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) }
.toMutableList() .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 { return@withContext updates.map {
Update.fromApp( 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? { private suspend fun getSelfUpdate(): App? = withContext(Dispatchers.IO) {
return withContext(Dispatchers.IO) {
val updateUrl = when (BuildType.CURRENT) { val updateUrl = when (BuildType.CURRENT) {
BuildType.RELEASE -> Constants.UPDATE_URL_STABLE BuildType.RELEASE -> Constants.UPDATE_URL_VANILLA
BuildType.NIGHTLY -> Constants.UPDATE_URL_NIGHTLY BuildType.NIGHTLY -> Constants.UPDATE_URL_NIGHTLY
else -> { else -> {
Log.i(TAG, "Self-updates are not available for this build!") Log.i(TAG, "Self-updates are not available for this build!")
return@withContext null return@withContext null
@@ -235,42 +259,31 @@ class UpdateWorker @AssistedInject constructor(
} }
try { try {
val response = httpClient.get(updateUrl, mapOf()) val selfUpdate = httpClient.call(updateUrl).use {
val selfUpdate = json.decodeFromString<SelfUpdate>(String(response.responseBytes)) json.decodeFromString<SelfUpdate>(it.body.string())
}
val isUpdate = when (BuildType.CURRENT) { val isNewer = when (BuildType.CURRENT) {
BuildType.NIGHTLY,
BuildType.RELEASE -> selfUpdate.versionCode > BuildConfig.VERSION_CODE BuildType.RELEASE -> selfUpdate.versionCode > BuildConfig.VERSION_CODE
BuildType.NIGHTLY -> selfUpdate.timestamp > BuildConfig.BUILD_TIMESTAMP
else -> false else -> false
} }
if (isUpdate) { if (isNewer && selfUpdate.downloadUrl.isNotBlank()) {
if (CertUtil.isFDroidApp(context, BuildConfig.APPLICATION_ID)) { return@withContext selfUpdate.toApp(context)
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
}
} }
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to check self-updates", exception) Log.e(TAG, "Failed to check self-updates", exception)
return@withContext null
} }
Log.i(TAG, "No self-updates found!") Log.i(TAG, "No self-updates found!")
return@withContext null return@withContext null
} }
}
private fun notifyUpdates(updates: List<Update>) { private fun notifyUpdates(updates: List<Update>) {
with(context.getSystemService<NotificationManager>()!!) { with(context.getSystemService<NotificationManager>()!!) {
notify( notify(
notificationID, NOTIFICATION_ID,
NotificationUtil.getUpdateNotification(context, updates) 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.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
@@ -22,58 +23,125 @@ import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.receiver.DownloadCancelReceiver import com.aurora.store.data.receiver.DownloadCancelReceiver
import com.aurora.store.data.receiver.DownloadRetryReceiver
import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.Download as AuroraDownload import com.aurora.store.data.room.download.Download as AuroraDownload
import com.aurora.store.data.room.update.Update import com.aurora.store.data.room.update.Update
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
object NotificationUtil { object NotificationUtil {
// Channel groups: headings the individual channels are filed under in system settings.
private const val GROUP_CHANNELS_ACTIVITY = "com.aurora.store.channels.ACTIVITY"
private const val GROUP_CHANNELS_ALERTS = "com.aurora.store.channels.ALERTS"
// Terminal install/failure notifications are bundled under a group so a bulk update
// shows a single collapsible summary instead of one notification per app.
private const val GROUP_INSTALLED = "com.aurora.store.INSTALLED"
private const val GROUP_FAILED = "com.aurora.store.FAILED"
// Fixed IDs for the two group summaries. Kept well clear of the per-app IDs
// (packageName.hashCode()) and the worker IDs (100/200/500/501).
private const val SUMMARY_ID_INSTALLED = 1_000_001
private const val SUMMARY_ID_FAILED = 1_000_002
// Successful installs are informational, so they expire on their own rather than
// lingering. Failures are actionable and persist until handled.
private val INSTALLED_TIMEOUT_MS = TimeUnit.HOURS.toMillis(6)
fun createNotificationChannel(context: Context) { fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService<NotificationManager>() val notificationManager = context.getSystemService<NotificationManager>()!!
val channels = ArrayList<NotificationChannel>()
channels.add( // Drop channels retired or superseded by a lower-importance replacement, so the
NotificationChannel( // user no longer sees stale entries in system settings.
Constants.NOTIFICATION_CHANNEL_INSTALL, Constants.LEGACY_NOTIFICATION_CHANNELS.forEach {
context.getString(R.string.notification_channel_install), notificationManager.deleteNotificationChannel(it)
NotificationManager.IMPORTANCE_HIGH
).apply {
setSound(null, null)
} }
)
channels.add( // Organise the channels under two headings in system settings: routine activity
NotificationChannel( // vs. things that need attention.
Constants.NOTIFICATION_CHANNEL_EXPORT, notificationManager.createNotificationChannelGroups(
context.getString(R.string.notification_channel_export), listOf(
NotificationManager.IMPORTANCE_HIGH 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( channels.add(
NotificationChannel( NotificationChannel(
Constants.NOTIFICATION_CHANNEL_DOWNLOADS, Constants.NOTIFICATION_CHANNEL_DOWNLOADS,
context.getString(R.string.notification_channel_downloads), context.getString(R.string.notification_channel_downloads),
NotificationManager.IMPORTANCE_MIN NotificationManager.IMPORTANCE_MIN
).apply {
group = GROUP_CHANNELS_ACTIVITY
setSound(null, null)
setShowBadge(false)
}
) )
// Silent confirmation that an app finished installing/updating. LOW so it lands
// in the shade without buzzing for every app during a bulk update.
channels.add(
NotificationChannel(
Constants.NOTIFICATION_CHANNEL_INSTALL,
context.getString(R.string.notification_channel_install),
NotificationManager.IMPORTANCE_LOW
).apply {
group = GROUP_CHANNELS_ACTIVITY
setSound(null, null)
}
) )
// New updates are available to install. DEFAULT but silent.
channels.add( channels.add(
NotificationChannel( NotificationChannel(
Constants.NOTIFICATION_CHANNEL_UPDATES, Constants.NOTIFICATION_CHANNEL_UPDATES,
context.getString(R.string.notification_channel_updates), context.getString(R.string.notification_channel_updates),
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
).apply { ).apply {
group = GROUP_CHANNELS_ACTIVITY
setSound(null, null) setSound(null, null)
} }
) )
notificationManager!!.createNotificationChannels(channels)
// Background app export. LOW since it's user-initiated and non-urgent.
channels.add(
NotificationChannel(
Constants.NOTIFICATION_CHANNEL_EXPORT,
context.getString(R.string.notification_channel_export),
NotificationManager.IMPORTANCE_LOW
).apply {
group = GROUP_CHANNELS_ACTIVITY
setSound(null, null)
}
)
// Things the user genuinely needs to act on: failed downloads/installs, pending
// user action and authentication prompts. HIGH so these aren't missed.
channels.add(
NotificationChannel(
Constants.NOTIFICATION_CHANNEL_ALERTS,
context.getString(R.string.notification_channel_alerts),
NotificationManager.IMPORTANCE_HIGH
).apply {
group = GROUP_CHANNELS_ALERTS
}
)
notificationManager.createNotificationChannels(channels)
} }
} }
@@ -91,7 +159,14 @@ object NotificationUtil {
largeIcon: Bitmap? = null, largeIcon: Bitmap? = null,
message: String? = null message: String? = null
): Notification { ): Notification {
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_DOWNLOADS) // Terminal failures go to the high-importance alerts channel so they aren't lost in
// the silent downloads channel; everything else stays quiet.
val channelId = if (download.status == DownloadStatus.FAILED) {
Constants.NOTIFICATION_CHANNEL_ALERTS
} else {
Constants.NOTIFICATION_CHANNEL_DOWNLOADS
}
val builder = NotificationCompat.Builder(context, channelId)
builder.setSmallIcon(R.drawable.ic_notification_outlined) builder.setSmallIcon(R.drawable.ic_notification_outlined)
builder.setContentTitle(download.displayName) builder.setContentTitle(download.displayName)
builder.setContentIntent(getContentIntentForDetails(context, download.packageName)) builder.setContentIntent(getContentIntentForDetails(context, download.packageName))
@@ -122,6 +197,9 @@ object NotificationUtil {
builder.setContentText(message ?: context.getString(R.string.download_failed)) builder.setContentText(message ?: context.getString(R.string.download_failed))
builder.color = Color.RED builder.color = Color.RED
builder.setCategory(Notification.CATEGORY_ERROR) builder.setCategory(Notification.CATEGORY_ERROR)
builder.setGroup(GROUP_FAILED)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
builder.addAction(getRetryAction(context, download.packageName))
} }
DownloadStatus.COMPLETED -> { DownloadStatus.COMPLETED -> {
@@ -175,6 +253,15 @@ object NotificationUtil {
) )
} }
DownloadStatus.INSTALLING -> {
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setContentText(context.getString(R.string.status_installing))
builder.setOngoing(true)
builder.setCategory(Notification.CATEGORY_PROGRESS)
builder.setProgress(100, 100, true)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
}
else -> {} else -> {}
} }
return builder.build() return builder.build()
@@ -190,6 +277,11 @@ object NotificationUtil {
.setContentTitle(displayName) .setContentTitle(displayName)
.setContentText(context.getString(R.string.installer_status_success)) .setContentText(context.getString(R.string.installer_status_success))
.setContentIntent(getContentIntentForDetails(context, packageName)) .setContentIntent(getContentIntentForDetails(context, packageName))
.setCategory(Notification.CATEGORY_STATUS)
.setGroup(GROUP_INSTALLED)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setAutoCancel(true)
.setTimeoutAfter(INSTALLED_TIMEOUT_MS)
.build() .build()
fun getInstallerStatusNotification( fun getInstallerStatusNotification(
@@ -197,13 +289,158 @@ object NotificationUtil {
packageName: String, packageName: String,
displayName: String, displayName: String,
content: String? content: String?
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_INSTALL) ): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS)
.setSmallIcon(R.drawable.ic_install) .setSmallIcon(R.drawable.ic_install)
.setContentTitle(displayName) .setContentTitle(displayName)
.setContentText(content) .setContentText(content)
.setContentIntent(getContentIntentForDetails(context, packageName)) .setContentIntent(getContentIntentForDetails(context, packageName))
.setCategory(Notification.CATEGORY_ERROR)
.setGroup(GROUP_FAILED)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.addAction(getRetryAction(context, packageName))
.build() .build()
/**
* Posts the grouped "app installed" notification and refreshes the group summary so a
* bulk update collapses into a single "N apps installed" entry.
*/
fun notifyInstalled(context: Context, displayName: String, packageName: String) {
val notificationManager = context.getSystemService<NotificationManager>()!!
notificationManager.notify(
packageName.hashCode(),
getInstallNotification(context, displayName, packageName)
)
refreshGroupSummaries(context)
}
/**
* Posts the grouped install-failure notification (with a retry action) and refreshes the
* failure group summary.
*/
fun notifyInstallFailed(
context: Context,
packageName: String,
displayName: String,
content: String?
) {
val notificationManager = context.getSystemService<NotificationManager>()!!
notificationManager.notify(
packageName.hashCode(),
getInstallerStatusNotification(context, packageName, displayName, content)
)
refreshGroupSummaries(context)
}
/**
* Cancels the per-app notification for [packageName] (e.g. once the user retries a failed
* install) and reconciles the group summaries.
*/
fun clearAppNotification(context: Context, packageName: String) {
context.getSystemService<NotificationManager>()!!.cancel(packageName.hashCode())
refreshGroupSummaries(context)
}
private fun getRetryAction(context: Context, packageName: String): NotificationCompat.Action {
val intent = Intent(context, DownloadRetryReceiver::class.java).apply {
putExtra(DownloadHelper.PACKAGE_NAME, packageName)
}
val pendingIntent = PendingIntentCompat.getBroadcast(
context,
packageName.hashCode().absoluteValue,
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false
)
return NotificationCompat.Action.Builder(
R.drawable.ic_updates,
context.getString(R.string.action_retry),
pendingIntent
).build()
}
/**
* Rebuilds the two group summaries from the currently-posted per-app notifications. Each
* summary lists its apps via [NotificationCompat.InboxStyle] and is removed once no
* children remain. Reading the live notifications (rather than tracking state ourselves)
* keeps the summaries correct across retries, dismissals and process restarts.
*/
fun refreshGroupSummaries(context: Context) {
// Grouping/summaries are only rendered from Android N onwards; on older versions a
// summary would just show as an extra standalone notification, so leave the children
// to display individually.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val notificationManager = context.getSystemService<NotificationManager>()!!
refreshSummary(
notificationManager = notificationManager,
context = context,
group = GROUP_INSTALLED,
summaryId = SUMMARY_ID_INSTALLED,
channelId = Constants.NOTIFICATION_CHANNEL_INSTALL,
smallIcon = R.drawable.ic_install,
titleRes = R.plurals.notification_installed_summary,
timeoutMs = INSTALLED_TIMEOUT_MS,
contentIntent = getContentIntentForMain(context, initialTab = 2)
)
refreshSummary(
notificationManager = notificationManager,
context = context,
group = GROUP_FAILED,
summaryId = SUMMARY_ID_FAILED,
channelId = Constants.NOTIFICATION_CHANNEL_ALERTS,
smallIcon = R.drawable.ic_download_fail,
titleRes = R.plurals.notification_failed_summary,
timeoutMs = null,
contentIntent = getContentIntentForMain(context, initialTab = 2)
)
}
@Suppress("LongParameterList")
private fun refreshSummary(
notificationManager: NotificationManager,
context: Context,
group: String,
summaryId: Int,
channelId: String,
smallIcon: Int,
titleRes: Int,
timeoutMs: Long?,
contentIntent: PendingIntent?
) {
val children = notificationManager.activeNotifications.filter {
it.notification.group == group && it.id != summaryId
}
if (children.isEmpty()) {
notificationManager.cancel(summaryId)
return
}
val titles = children.mapNotNull {
it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
}
val title = context.resources.getQuantityString(titleRes, children.size, children.size)
val inboxStyle = NotificationCompat.InboxStyle().setBigContentTitle(title)
titles.forEach { inboxStyle.addLine(it) }
val summary = NotificationCompat.Builder(context, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(title)
.setContentText(titles.joinToString(", "))
.setStyle(inboxStyle)
.setGroup(group)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setAutoCancel(true)
.setContentIntent(contentIntent)
.apply { timeoutMs?.let { setTimeoutAfter(it) } }
.build()
notificationManager.notify(summaryId, summary)
}
fun getUpdateNotification(context: Context): Notification = fun getUpdateNotification(context: Context): Notification =
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES) NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_updates) .setSmallIcon(R.drawable.ic_updates)
@@ -273,16 +510,23 @@ object NotificationUtil {
.build() .build()
} }
fun getExportNotification(context: Context): Notification = fun getExportNotification(
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT) context: Context,
displayName: String? = null,
progress: Int = -1
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT)
.setSmallIcon(R.drawable.ic_file_copy) .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)) .setContentText(context.getString(R.string.export_app_summary))
.setProgress(100, progress.coerceAtLeast(0), progress < 0)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_PROGRESS)
.setSilent(true)
.build() .build()
fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification = fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification =
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ACCOUNT) NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERTS)
.setSmallIcon(R.drawable.ic_account) .setSmallIcon(R.drawable.ic_account)
.setContentTitle(context.getString(R.string.authentication_required_title)) .setContentTitle(context.getString(R.string.authentication_required_title))
.setContentText(context.getString(R.string.authentication_required_unarchive)) .setContentText(context.getString(R.string.authentication_required_unarchive))

View File

@@ -36,6 +36,8 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri 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_APP_GALLERY
import com.aurora.Constants.PACKAGE_NAME_GMS import com.aurora.Constants.PACKAGE_NAME_GMS
import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE 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.extensions.isValidApp
import com.aurora.store.BuildConfig import com.aurora.store.BuildConfig
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.data.model.BuildType
import java.util.Locale import java.util.Locale
object PackageUtil { 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 * Confirm if MicroG bundle is installed
* Considering the following: * Considering the following:

View File

@@ -21,6 +21,7 @@ package com.aurora.store.util
import android.content.Context import android.content.Context
import android.os.Environment import android.os.Environment
import androidx.core.content.pm.PackageInfoCompat
import com.aurora.gplayapi.data.models.PlayFile import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download
import java.io.File 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( fun getZipFile(context: Context, packageName: String, versionCode: Long): File = File(
getAppDownloadDir( getAppDownloadDir(
context, context,

View File

@@ -43,6 +43,8 @@ object Preferences {
const val PREFERENCE_AUTO_DELETE = "PREFERENCE_AUTO_DELETE" const val PREFERENCE_AUTO_DELETE = "PREFERENCE_AUTO_DELETE"
const val PREFERENCE_NOTIFICATION_PROGRESS = "PREFERENCE_NOTIFICATION_PROGRESS"
const val PREFERENCE_INSTALLATION_DEVICE_OWNER = "PREFERENCE_INSTALLATION_DEVICE_OWNER" const val PREFERENCE_INSTALLATION_DEVICE_OWNER = "PREFERENCE_INSTALLATION_DEVICE_OWNER"
const val PREFERENCE_PROXY_URL = "PREFERENCE_PROXY_URL" const val PREFERENCE_PROXY_URL = "PREFERENCE_PROXY_URL"
@@ -74,6 +76,11 @@ object Preferences {
const val PREFERENCE_MIGRATION_VERSION = "PREFERENCE_MIGRATION_VERSION" 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 private var prefs: SharedPreferences? = null
fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) { fun getPrefs(context: Context): SharedPreferences = when (BuildConfig.FLAVOR) {

View File

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

View File

@@ -314,7 +314,8 @@ class AppDetailsViewModel @Inject constructor(
DownloadStatus.VERIFYING -> AppState.Verifying 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 else -> defaultAppState
} }

View File

@@ -75,7 +75,7 @@ class DownloadsViewModel @Inject constructor(
fun install(download: Download) { fun install(download: Download) {
try { try {
appInstaller.getPreferredInstaller().install(download) appInstaller.getPreferredInstaller(notifyOnFallback = true).install(download)
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to install ${download.packageName}", 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 == null -> InstallStatus.PENDING
download.status == DownloadStatus.FAILED -> InstallStatus.FAILED download.status == DownloadStatus.FAILED -> InstallStatus.FAILED
download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING 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 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_min">%1$dm %2$ds left</string>
<string name="download_eta_sec">%1$ds left</string> <string name="download_eta_sec">%1$ds left</string>
<string name="download_failed">"Download failed"</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_force_clear_all">"Force clear all"</string>
<string name="download_metadata">"Getting metadata"</string> <string name="download_metadata">"Getting metadata"</string>
<string name="download_none">"No downloads"</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_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_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_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_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_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> <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="device_miui_extra">"Optionally you can choose Native installer, but then you can not install bundled (split) APKs, so choice is yours."</string>
<string name="dialog_title_self_update">"New update available"</string> <string name="dialog_title_self_update">"New update available"</string>
<string name="dialog_desc_native_split">"You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root."</string> <string name="dialog_desc_native_split">"You can not install bundled (split) apps via Native Installer. Change your installer to Session, Services or Root."</string>
<string name="notification_channel_account">Account-related notification</string> <string name="notification_channel_alerts">Errors &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_export">App export notification</string>
<string name="notification_channel_install">Install notification</string> <string name="notification_channel_install">Install notification</string>
<string name="notification_channel_downloads">Downloads notification</string> <string name="notification_channel_downloads">Downloads notification</string>
@@ -228,6 +242,8 @@
<string name="pref_ui_similar_apps">"Similar and related apps"</string> <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">"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_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">"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_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> <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_no_network">"No network"</string>
<string name="title_purchase_history">"Purchase history"</string> <string name="title_purchase_history">"Purchase history"</string>
<string name="title_settings">"Settings"</string> <string name="title_settings">"Settings"</string>
<string name="title_notifications">"Notifications"</string>
<string name="pref_notification_progress">Show download progress</string>
<string name="pref_notification_progress_desc">Show detailed progress while downloading and updating apps. When off, a single quiet notification keeps downloads running in the background.</string>
<string name="pref_notification_categories">Categories</string>
<string name="pref_notification_categories_desc">Tune sound and importance for each kind of notification</string>
<string name="pref_notification_disabled">Notifications are turned off</string>
<string name="pref_notification_disabled_desc">Tap to enable notifications for Aurora Store</string>
<string name="title_spoof_manager">"Spoof manager"</string> <string name="title_spoof_manager">"Spoof manager"</string>
<string name="title_search_suggestion">"Search suggestion"</string> <string name="title_search_suggestion">"Search suggestion"</string>
<string name="title_search_results">"Search results"</string> <string name="title_search_results">"Search results"</string>
@@ -317,6 +340,8 @@
<string name="app_updater_service_notif_text">Enables background app download</string> <string name="app_updater_service_notif_text">Enables background app download</string>
<string name="update_available">update available</string> <string name="update_available">update available</string>
<string name="updates_available">updates 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_header">Incompatible updates</string>
<string name="updates_incompatible_desc">These system app updates cannot be installed on this OS</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> <string name="updates_approval_header">Updates requiring approval</string>
@@ -516,6 +541,8 @@
<string name="status_queued">Queued</string> <string name="status_queued">Queued</string>
<string name="status_unavailable">Unavailable</string> <string name="status_unavailable">Unavailable</string>
<string name="status_verifying">Verifying</string> <string name="status_verifying">Verifying</string>
<string name="status_installing">Installing</string>
<string name="status_installed">Installed</string>
<!-- UnarchivePackageReceiver --> <!-- UnarchivePackageReceiver -->
<string name="authentication_required_title">Authentication required</string> <string name="authentication_required_title">Authentication required</string>
@@ -558,6 +585,14 @@
<item quantity="one">Permission required</item> <item quantity="one">Permission required</item>
<item quantity="other">Permissions required</item> <item quantity="other">Permissions required</item>
</plurals> </plurals>
<plurals name="notification_installed_summary">
<item quantity="one">%d app installed</item>
<item quantity="other">%d apps installed</item>
</plurals>
<plurals name="notification_failed_summary">
<item quantity="one">%d app failed</item>
<item quantity="other">%d apps failed</item>
</plurals>
<!-- AppsGamesFragment --> <!-- AppsGamesFragment -->
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string> <string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>

View File

@@ -11,6 +11,7 @@ agCoreservice = "13.3.1.300"
androidGradlePlugin = "9.2.1" androidGradlePlugin = "9.2.1"
androidx-hilt = "1.3.0" androidx-hilt = "1.3.0"
androidx-junit = "1.3.0" androidx-junit = "1.3.0"
biometric = "1.1.0"
browser = "1.10.0" browser = "1.10.0"
coil = "3.4.0" coil = "3.4.0"
composeBom = "2026.05.01" 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-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-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-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-browser = { module = "androidx.browser:browser", version.ref = "browser" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }