Merge branch 'dev' into 'master'
dev See merge request AuroraOSS/AuroraStore!575
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {})
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.preferences
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import com.aurora.Constants
|
||||||
|
import com.aurora.extensions.areNotificationsEnabled
|
||||||
|
import com.aurora.extensions.isOAndAbove
|
||||||
|
import com.aurora.store.R
|
||||||
|
import com.aurora.store.compose.composable.TopAppBar
|
||||||
|
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||||
|
import com.aurora.store.util.Preferences
|
||||||
|
import com.aurora.store.util.Preferences.PREFERENCE_NOTIFICATION_PROGRESS
|
||||||
|
import com.aurora.store.util.save
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationPreferenceScreen() {
|
||||||
|
ScreenContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScreenContent() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var notificationsEnabled by remember { mutableStateOf(context.areNotificationsEnabled()) }
|
||||||
|
var showProgress by remember {
|
||||||
|
mutableStateOf(Preferences.getBoolean(context, PREFERENCE_NOTIFICATION_PROGRESS, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read the system notification state when returning from settings.
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
notificationsEnabled = context.areNotificationsEnabled()
|
||||||
|
onPauseOrDispose {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The channels users most likely want to fine-tune (sound, importance, on/off), paired
|
||||||
|
// with their display-name strings, deep-linked into the system per-channel settings.
|
||||||
|
val channels = listOf(
|
||||||
|
Constants.NOTIFICATION_CHANNEL_DOWNLOADS to R.string.notification_channel_downloads,
|
||||||
|
Constants.NOTIFICATION_CHANNEL_INSTALL to R.string.notification_channel_install,
|
||||||
|
Constants.NOTIFICATION_CHANNEL_UPDATES to R.string.notification_channel_updates,
|
||||||
|
Constants.NOTIFICATION_CHANNEL_ALERTS to R.string.notification_channel_alerts,
|
||||||
|
Constants.NOTIFICATION_CHANNEL_EXPORT to R.string.notification_channel_export
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = stringResource(R.string.title_notifications)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { openAppNotificationSettings(context) },
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.pref_notification_disabled))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(stringResource(R.string.pref_notification_disabled_desc))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
showProgress = !showProgress
|
||||||
|
context.save(PREFERENCE_NOTIFICATION_PROGRESS, showProgress)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.pref_notification_progress))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(stringResource(R.string.pref_notification_progress_desc))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = showProgress,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
showProgress = checked
|
||||||
|
context.save(PREFERENCE_NOTIFICATION_PROGRESS, checked)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-channel system settings are only meaningful on Android O+ where channels
|
||||||
|
// exist; on older versions the app-level toggle above is the only control.
|
||||||
|
if (isOAndAbove) {
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.pref_notification_categories))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(stringResource(R.string.pref_notification_categories_desc))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
channels.forEach { (channelId, nameRes) ->
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
openChannelSettings(context, channelId)
|
||||||
|
},
|
||||||
|
headlineContent = { Text(stringResource(nameRes)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAppNotificationSettings(context: android.content.Context) {
|
||||||
|
val intent = if (isOAndAbove) {
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
} else {
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
.setData("package:${context.packageName}".toUri())
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openChannelSettings(context: android.content.Context, channelId: String) {
|
||||||
|
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun NotificationPreferenceScreenPreview() {
|
||||||
|
ScreenContent()
|
||||||
|
}
|
||||||
@@ -88,6 +88,20 @@ private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) {
|
|||||||
headlineContent = { Text(stringResource(R.string.pref_ui_title)) }
|
headlineContent = { Text(stringResource(R.string.pref_ui_title)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
onNavigateTo(Destination.NotificationPreference)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_notification_settings),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = { Text(stringResource(R.string.title_notifications)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },
|
modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) },
|
||||||
@@ -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)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
64
app/src/main/java/com/aurora/store/data/AppLockManager.kt
Normal file
64
app/src/main/java/com/aurora/store/data/AppLockManager.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.aurora.store.data.receiver
|
package com.aurora.store.data.receiver
|
||||||
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -26,7 +25,6 @@ import android.content.pm.PackageInstaller
|
|||||||
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
|
import android.content.pm.PackageInstaller.EXTRA_SESSION_ID
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
import com.aurora.extensions.runOnUiThread
|
import com.aurora.extensions.runOnUiThread
|
||||||
import com.aurora.store.AuroraApp
|
import com.aurora.store.AuroraApp
|
||||||
@@ -92,14 +90,12 @@ abstract class BaseInstallerStatusReceiver : BroadcastReceiver() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
status: Int
|
status: Int
|
||||||
) {
|
) {
|
||||||
val notificationManager = context.getSystemService<NotificationManager>()
|
NotificationUtil.notifyInstallFailed(
|
||||||
val notification = NotificationUtil.getInstallerStatusNotification(
|
|
||||||
context,
|
context,
|
||||||
packageName,
|
packageName,
|
||||||
displayName,
|
displayName,
|
||||||
InstallerBase.getErrorString(context, status)
|
InstallerBase.getErrorString(context, status)
|
||||||
)
|
)
|
||||||
notificationManager?.notify(packageName.hashCode(), notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun promptUser(context: Context, intent: Intent) {
|
internal fun promptUser(context: Context, intent: Intent) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.data.receiver
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.aurora.extensions.TAG
|
||||||
|
import com.aurora.store.AuroraApp
|
||||||
|
import com.aurora.store.data.helper.DownloadHelper
|
||||||
|
import com.aurora.store.util.NotificationUtil
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-queues a previously failed download/install when the user taps "Retry" on its
|
||||||
|
* (grouped) failure notification.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class DownloadRetryReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var downloadHelper: DownloadHelper
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
val packageName = intent?.getStringExtra(DownloadHelper.PACKAGE_NAME) ?: ""
|
||||||
|
|
||||||
|
if (packageName.isNotBlank()) {
|
||||||
|
Log.d(TAG, "Received retry request for $packageName")
|
||||||
|
// Clear the failure notification immediately for responsive feedback; the
|
||||||
|
// download worker re-posts progress once it runs.
|
||||||
|
NotificationUtil.clearAppNotification(context, packageName)
|
||||||
|
AuroraApp.scope.launch(Dispatchers.IO) {
|
||||||
|
downloadHelper.retryDownload(packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
app/src/main/res/drawable/ic_lock.xml
Normal file
24
app/src/main/res/drawable/ic_lock.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright (C) 2026 The Android Open Source Project
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:pathData="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>
|
||||||
24
app/src/main/res/drawable/ic_notification_settings.xml
Normal file
24
app/src/main/res/drawable/ic_notification_settings.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright (C) 2026 The Android Open Source Project
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:pathData="M480,471ZM480,880q-33,0 -56.5,-23.5T400,800h160q0,33 -23.5,56.5T480,880ZM160,760v-80h80v-280q0,-84 50.5,-149T422,167q-10,22 -15.5,46t-7.5,49q-35,21 -57,57t-22,81v280h320v-122q20,3 40,3t40,-3v122h80v80L160,760ZM640,480 L628,420q-12,-5 -22.5,-10.5T584,396l-58,18 -40,-68 46,-40q-2,-13 -2,-26t2,-26l-46,-40 40,-68 58,18q11,-8 21.5,-13.5T628,140l12,-60h80l12,60q12,5 22.5,10.5T776,164l58,-18 40,68 -46,40q2,13 2,26t-2,26l46,40 -40,68 -58,-18q-11,8 -21.5,13.5T732,420l-12,60h-80ZM736.5,336.5Q760,313 760,280t-23.5,-56.5Q713,200 680,200t-56.5,23.5Q600,247 600,280t23.5,56.5Q647,360 680,360t56.5,-23.5Z"
|
||||||
|
android:fillColor="#e3e3e3"/>
|
||||||
|
</vector>
|
||||||
@@ -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 & alerts</string>
|
||||||
|
<string name="notification_group_activity">Downloads & updates</string>
|
||||||
|
<string name="notification_group_alerts">Alerts</string>
|
||||||
<string name="notification_channel_export">App export notification</string>
|
<string name="notification_channel_export">App export notification</string>
|
||||||
<string name="notification_channel_install">Install notification</string>
|
<string name="notification_channel_install">Install notification</string>
|
||||||
<string name="notification_channel_downloads">Downloads notification</string>
|
<string name="notification_channel_downloads">Downloads notification</string>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user