updates: revamp updates UX

- Group updates into main / approval-required / incompatible / ignored
- New ignored_update table (v8) with per-version skip support
- isIncompatible flag on pristine system apps for HyperOS / GrapheneOS (v7)
- requiresOwnershipTransfer() runtime check on Android 14+
- Rename AppMenuSheet -> AppUpdateSheet; drop MinimalApp, nav carries Update
- Trim AppUpdateItem (no changelog dropdown / long-click)
- Fix DownloadHelper.downloadsList recomposition flicker (stable StateFlow)
- ExportWorker is now download-only
This commit is contained in:
Rahul Patel
2026-05-19 10:45:26 +05:30
parent f5ef5e71e9
commit 1e191e13c8
31 changed files with 1383 additions and 450 deletions

View File

@@ -6,6 +6,7 @@
package com.aurora.store.compose.composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -68,13 +69,15 @@ fun SectionHeader(
}
/**
* Section header row with a right-aligned action button (replaces UpdateHeaderView).
* Section header row with optional subtitle and right-aligned action button
* (replaces UpdateHeaderView). Pass `action = null` to omit the button entirely.
*/
@Composable
fun SectionHeaderWithAction(
modifier: Modifier = Modifier,
title: String,
action: String,
action: String? = null,
subtitle: String? = null,
onAction: () -> Unit = {}
) {
Row(
@@ -87,16 +90,28 @@ fun SectionHeaderWithAction(
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onAction) {
Text(action)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
if (!action.isNullOrBlank()) {
TextButton(onClick = onAction) {
Text(action)
}
}
}
}

View File

@@ -42,7 +42,6 @@ fun StreamCarousel(
lazyListState: LazyListState = rememberLazyListState(),
onHeaderClick: (StreamCluster) -> Unit = {},
onAppClick: (App) -> Unit = {},
onAppLongClick: (App) -> Unit = {},
onClusterScrolled: (StreamCluster) -> Unit = {},
onScrolledToEnd: () -> Unit = {}
) {
@@ -118,7 +117,6 @@ fun StreamCarousel(
ClusterRow(
cluster = cluster,
onAppClick = onAppClick,
onAppLongClick = onAppLongClick,
onClusterScrolled = onClusterScrolled
)
}
@@ -135,7 +133,6 @@ fun StreamCarousel(
internal fun ClusterRow(
cluster: StreamCluster,
onAppClick: (App) -> Unit = {},
onAppLongClick: (App) -> Unit = {},
onClusterScrolled: (StreamCluster) -> Unit = {}
) {
val rowState = rememberLazyListState()
@@ -162,8 +159,7 @@ internal fun ClusterRow(
) { _, app ->
AppListItem(
app = app,
onClick = { onAppClick(app) },
onLongClick = { onAppLongClick(app) }
onClick = { onAppClick(app) }
)
}
if (cluster.hasNext()) {

View File

@@ -5,8 +5,7 @@
package com.aurora.store.compose.composable.app
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
@@ -41,21 +40,14 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
* @param modifier The modifier to be applied to the composable
* @param app [App] to display
* @param onClick Callback when the composable is clicked
* @param onLongClick Callback when the composable is long-clicked
* @see LargeAppListItem
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppListItem(
modifier: Modifier = Modifier,
app: App,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
fun AppListItem(modifier: Modifier = Modifier, app: App, onClick: () -> Unit = {}) {
Column(
modifier = modifier
.width(dimensionResource(R.dimen.icon_size_cluster))
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.clickable(onClick = onClick)
.padding(all = dimensionResource(R.dimen.padding_xxsmall)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(

View File

@@ -5,9 +5,7 @@
package com.aurora.store.compose.composable.app
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -16,26 +14,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
@@ -52,9 +39,9 @@ fun AppUpdateItem(
update: Update,
download: Download? = null,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onUpdate: () -> Unit = {},
onCancel: () -> Unit = {}
onCancel: () -> Unit = {},
onUnignore: (() -> Unit)? = null
) {
val inProgress = download != null && download.status !in DownloadStatus.finished
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
@@ -62,99 +49,68 @@ fun AppUpdateItem(
} else {
0f
}
var changelogExpanded by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))) {
AnimatedAppIcon(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
iconUrl = update.iconURL,
inProgress = inProgress,
progress = progress
)
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))) {
AnimatedAppIcon(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
iconUrl = update.iconURL,
inProgress = inProgress,
progress = progress
)
}
Spacer(Modifier.width(dimensionResource(R.dimen.margin_medium)))
Column(modifier = Modifier.weight(1f)) {
Text(
text = update.displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = update.developerName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${update.versionName}${CommonUtil.addSiPrefix(update.size)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(Modifier.width(dimensionResource(R.dimen.margin_small)))
when {
onUnignore != null -> {
OutlinedButton(onClick = onUnignore) {
Text(stringResource(R.string.action_unignore))
}
}
Spacer(Modifier.width(dimensionResource(R.dimen.margin_small)))
Column(modifier = Modifier.weight(1f)) {
Text(
text = update.displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = update.developerName,
style = MaterialTheme.typography.bodySmall
)
Text(
text = "${CommonUtil.addSiPrefix(update.size)}${update.updatedOn}",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "${update.versionName} (${update.versionCode})",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(Modifier.width(dimensionResource(R.dimen.margin_small)))
if (inProgress) {
inProgress -> {
OutlinedButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
} else {
}
else -> {
Button(onClick = onUpdate) {
Text(stringResource(R.string.action_update))
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { changelogExpanded = !changelogExpanded }
.padding(horizontal = dimensionResource(R.dimen.padding_medium)),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.weight(1f))
IconButton(onClick = { changelogExpanded = !changelogExpanded }) {
Icon(
painter = painterResource(
if (changelogExpanded) R.drawable.ic_arrow_up else R.drawable.ic_arrow_down
),
contentDescription = stringResource(R.string.expand)
)
}
}
AnimatedVisibility(visible = changelogExpanded) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
),
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small))
) {
Text(
text = if (update.changelog.isNotEmpty()) {
AnnotatedString.fromHtml(update.changelog)
} else {
AnnotatedString(stringResource(R.string.details_changelog_unavailable))
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium))
)
}
}
}
}

View File

@@ -7,8 +7,8 @@ package com.aurora.store.compose.navigation
import com.aurora.gplayapi.data.models.Category
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.room.update.Update
/**
* All navigation actions available to composable screens.
@@ -20,7 +20,7 @@ sealed class Destination {
data class AppDetails(val packageName: String) : Destination()
data class DevProfile(val devId: String) : Destination()
data class AppMenu(val app: MinimalApp) : Destination()
data class AppUpdate(val update: Update) : Destination()
data object Search : Destination()
data object Downloads : Destination()

View File

@@ -128,7 +128,7 @@ fun NavDisplay(startDestination: NavKey) {
is Destination.AppDetails -> backstack.add(Screen.AppDetails(destination.packageName))
is Destination.DevProfile -> backstack.add(Screen.DevProfile(destination.devId))
is Destination.AppMenu -> Unit
is Destination.AppUpdate -> Unit
is Destination.StreamBrowse -> backstack.add(Screen.StreamBrowse(destination.cluster))
Destination.Search -> backstack.add(Screen.Search)

View File

@@ -26,7 +26,6 @@ import com.aurora.gplayapi.helpers.contracts.StreamContract
import com.aurora.gplayapi.helpers.contracts.TopChartsContract
import com.aurora.store.R
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.util.Preferences
import com.aurora.store.viewmodel.category.CategoryViewModel
import com.aurora.store.viewmodel.homestream.StreamViewModel
@@ -100,7 +99,6 @@ fun AppsGamesScreen(
pageType = pageType,
viewModel = streamViewModel,
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
onAppLongClick = { onNavigateTo(Destination.AppMenu(MinimalApp.fromApp(it))) },
onHeaderClick = { onNavigateTo(Destination.StreamBrowse(it)) },
onClusterScrolled = { cluster ->
streamViewModel.observeCluster(category(pageType), cluster)

View File

@@ -24,7 +24,6 @@ internal fun ForYouContent(
pageType: Int,
viewModel: StreamViewModel,
onAppClick: (App) -> Unit,
onAppLongClick: (App) -> Unit,
onHeaderClick: (StreamCluster) -> Unit,
onClusterScrolled: (StreamCluster) -> Unit,
onScrolledToEnd: () -> Unit
@@ -43,7 +42,6 @@ internal fun ForYouContent(
streamBundle = streamBundle?.get(category),
onHeaderClick = onHeaderClick,
onAppClick = onAppClick,
onAppLongClick = onAppLongClick,
onClusterScrolled = onClusterScrolled,
onScrolledToEnd = onScrolledToEnd
)

View File

@@ -43,12 +43,12 @@ import com.aurora.store.compose.navigation.Destination
import com.aurora.store.compose.ui.apps.AppsGamesScreen
import com.aurora.store.compose.ui.commons.MoreSheet
import com.aurora.store.compose.ui.commons.NetworkSheet
import com.aurora.store.compose.ui.sheets.AppMenuSheet
import com.aurora.store.compose.ui.sheets.AppUpdateSheet
import com.aurora.store.compose.ui.updates.UpdatesScreen
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
import com.aurora.store.data.room.update.Update
import com.aurora.store.viewmodel.all.UpdatesViewModel
import kotlinx.coroutines.launch
@@ -88,12 +88,12 @@ fun MainScreen(
}
var showMoreSheet by remember { mutableStateOf(false) }
var appMenuTarget by remember { mutableStateOf<MinimalApp?>(null) }
var appUpdateTarget by remember { mutableStateOf<Update?>(null) }
var showNetworkDialog by remember { mutableStateOf(false) }
fun handleNavigation(destination: Destination) {
when (destination) {
is Destination.AppMenu -> appMenuTarget = destination.app
is Destination.AppUpdate -> appUpdateTarget = destination.update
else -> onNavigateTo(destination)
}
}
@@ -116,8 +116,15 @@ fun MainScreen(
)
}
appMenuTarget?.let { app ->
AppMenuSheet(app = app, onDismiss = { appMenuTarget = null })
appUpdateTarget?.let { app ->
AppUpdateSheet(
update = app,
onDismiss = { appUpdateTarget = null },
onNavigateTo = { destination ->
appUpdateTarget = null
onNavigateTo(destination)
}
)
}
Scaffold(
@@ -192,7 +199,7 @@ fun MainScreen(
when (MainTab.entries[page]) {
MainTab.APPS -> AppsGamesScreen(
pageType = 0,
onNavigateTo = ::handleNavigation
onNavigateTo = onNavigateTo
)
MainTab.GAMES -> AppsGamesScreen(
pageType = 1,
@@ -214,10 +221,10 @@ fun MainScreen(
updatesViewModel.download(update)
}
},
onRequestUpdateAll = {
val needsObb = updatesViewModel.updates.value?.any {
onRequestUpdateAll = { selectedUpdates ->
val needsObb = selectedUpdates.any {
it.fileList.requiresObbDir()
} == true
}
if (needsObb && !isGranted(context, PermissionType.STORAGE_MANAGER)) {
onNavigateTo(
Destination.PermissionRationale(
@@ -225,7 +232,7 @@ fun MainScreen(
)
)
} else {
updatesViewModel.downloadAll()
updatesViewModel.downloadAll(selectedUpdates)
}
},
onCancelUpdate = { packageName ->

View File

@@ -1,148 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.sheets
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.aurora.extensions.openInfo
import com.aurora.extensions.toast
import com.aurora.store.AuroraApp
import com.aurora.store.R
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.util.PackageUtil
import com.aurora.store.viewmodel.sheets.AppMenuViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppMenuSheet(
app: MinimalApp,
onDismiss: () -> Unit,
viewModel: AppMenuViewModel = hiltViewModel()
) {
val context = LocalContext.current
val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(app.packageName)
val isInstalled = PackageUtil.isInstalled(context, app.packageName)
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
if (uri != null) {
viewModel.copyInstalledApp(context, app, uri)
} else {
context.toast(R.string.failed_apk_export)
}
onDismiss()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(
if (isBlacklisted) R.drawable.ic_cancel else R.drawable.ic_blacklist
),
contentDescription = null
)
},
label = {
val labelRes = if (isBlacklisted) {
R.string.action_whitelist
} else {
R.string.action_blacklist_add
}
Text(stringResource(labelRes))
},
selected = false,
onClick = {
if (isBlacklisted) {
viewModel.blacklistProvider.whitelist(app.packageName)
context.toast(R.string.toast_apk_whitelisted)
} else {
viewModel.blacklistProvider.blacklist(app.packageName)
context.toast(R.string.toast_apk_blacklisted)
}
AuroraApp.events.send(BusEvent.Blacklisted(app.packageName))
onDismiss()
},
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
HorizontalDivider()
if (isInstalled) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_delete_forever),
contentDescription = null
)
},
label = { Text(stringResource(R.string.action_uninstall)) },
selected = false,
onClick = {
AppInstaller.uninstall(context, app.packageName)
onDismiss()
},
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_file_copy),
contentDescription = null
)
},
label = { Text(stringResource(R.string.action_export)) },
selected = false,
onClick = { exportLauncher.launch("${app.packageName}.zip") },
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
}
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_about),
contentDescription = null
)
},
label = { Text(stringResource(R.string.action_info)) },
selected = false,
onClick = {
context.openInfo(app.packageName)
onDismiss()
},
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
HorizontalDivider()
androidx.compose.foundation.layout.Spacer(Modifier.navigationBarsPadding())
}
}

View File

@@ -0,0 +1,245 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.sheets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.extensions.openInfo
import com.aurora.extensions.toast
import com.aurora.store.AuroraApp
import com.aurora.store.R
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.room.update.Update
import com.aurora.store.viewmodel.sheets.AppUpdateViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppUpdateSheet(
update: Update,
onDismiss: () -> Unit,
onNavigateTo: (Destination) -> Unit = {},
viewModel: AppUpdateViewModel = hiltViewModel()
) {
val context = LocalContext.current
val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(update.packageName)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
AppHeader(
update = update,
onShowDetails = {
onNavigateTo(Destination.AppDetails(update.packageName))
onDismiss()
}
)
if (update.changelog.isNotEmpty()) {
ChangelogSection(html = update.changelog)
}
HorizontalDivider(
modifier = Modifier.padding(
vertical = dimensionResource(R.dimen.padding_small)
)
)
Item(
label = stringResource(R.string.action_ignore_all),
onClick = {
viewModel.ignoreAllUpdates(update.packageName)
onDismiss()
}
)
Item(
label = stringResource(
R.string.action_ignore_version,
update.versionName
),
onClick = {
viewModel.ignoreVersion(update.packageName, update.versionCode)
onDismiss()
}
)
HorizontalDivider(
modifier = Modifier.padding(
vertical = dimensionResource(R.dimen.padding_small)
)
)
Item(
label = stringResource(
if (isBlacklisted) {
R.string.action_whitelist
} else {
R.string.action_blacklist_add
}
),
onClick = {
if (isBlacklisted) {
viewModel.blacklistProvider.whitelist(update.packageName)
context.toast(R.string.toast_apk_whitelisted)
} else {
viewModel.blacklistProvider.blacklist(update.packageName)
context.toast(R.string.toast_apk_blacklisted)
}
AuroraApp.events.send(BusEvent.Blacklisted(update.packageName))
onDismiss()
}
)
Item(
label = stringResource(R.string.action_uninstall),
onClick = {
AppInstaller.uninstall(context, update.packageName)
onDismiss()
}
)
Item(
label = stringResource(R.string.action_info),
onClick = {
context.openInfo(update.packageName)
onDismiss()
}
)
Spacer(Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun AppHeader(update: Update, onShowDetails: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_xsmall)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(update.iconURL)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = update.displayName,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = update.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stringResource(R.string.version, update.versionName, update.versionCode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
FilledTonalButton(onClick = onShowDetails) {
Text(stringResource(R.string.updates_app_details))
}
}
}
@Composable
private fun Item(label: String, onClick: () -> Unit) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.padding_xsmall))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_normal)))
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
)
)
}
@Composable
private fun ChangelogSection(html: String) {
Text(
text = stringResource(R.string.details_changelog),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_xsmall)
)
)
Text(
text = AnnotatedString.fromHtml(html),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_xsmall)
)
)
}

View File

@@ -8,12 +8,14 @@ package com.aurora.store.compose.ui.updates
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -24,7 +26,7 @@ import com.aurora.store.compose.composable.SectionHeaderWithAction
import com.aurora.store.compose.composable.ShimmerUpdateItem
import com.aurora.store.compose.composable.app.AppUpdateItem
import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.update.Update
import com.aurora.store.viewmodel.all.UpdatesViewModel
@@ -33,11 +35,13 @@ fun UpdatesScreen(
viewModel: UpdatesViewModel = hiltViewModel(),
onNavigateTo: (Destination) -> Unit = {},
onRequestUpdate: (Update) -> Unit = {},
onRequestUpdateAll: () -> Unit = {},
onRequestUpdateAll: (List<Update>) -> Unit = {},
onCancelUpdate: (String) -> Unit = {},
onCancelAll: () -> Unit = {}
) {
val context = LocalContext.current
val updates by viewModel.updates.collectAsStateWithLifecycle()
val ignoredUpdates by viewModel.ignoredUpdates.collectAsStateWithLifecycle()
val downloads by viewModel.downloadsList.collectAsStateWithLifecycle()
val fetchingUpdates by viewModel.fetchingUpdates.collectAsStateWithLifecycle()
@@ -49,8 +53,24 @@ fun UpdatesScreen(
}
}
}
val allEnqueued = updateMap?.isNotEmpty() == true &&
updateMap.values.all { it?.isRunning == true }
val groupedUpdates = remember(updateMap) {
val main = mutableListOf<Map.Entry<Update, Download?>>()
val approval = mutableListOf<Map.Entry<Update, Download?>>()
val incompatible = mutableListOf<Map.Entry<Update, Download?>>()
updateMap?.entries?.forEach { entry ->
when {
entry.key.isIncompatible -> incompatible += entry
entry.key.requiresOwnershipTransfer(context) -> approval += entry
else -> main += entry
}
}
Triple(main.toList(), approval.toList(), incompatible.toList())
}
val (mainEntries, approvalEntries, incompatibleEntries) = groupedUpdates
val mainAllEnqueued = mainEntries.isNotEmpty() &&
mainEntries.all { it.value?.isRunning == true }
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
@@ -80,48 +100,128 @@ fun UpdatesScreen(
}
else -> {
val title = "${updateMap.size} " + stringResource(
if (updateMap.size == 1) {
R.string.update_available
} else {
R.string.updates_available
}
)
val actionLabel = stringResource(
if (allEnqueued) R.string.action_cancel else R.string.action_update_all
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.margin_medium)
)
) {
item(key = "header") {
SectionHeaderWithAction(
title = title,
action = actionLabel,
onAction = {
if (allEnqueued) onCancelAll() else onRequestUpdateAll()
}
if (mainEntries.isNotEmpty()) {
item(key = "header_main") {
val title = "${mainEntries.size} " + stringResource(
if (mainEntries.size == 1) {
R.string.update_available
} else {
R.string.updates_available
}
)
val actionLabel = stringResource(
if (mainAllEnqueued) {
R.string.action_cancel
} else {
R.string.action_update_all
}
)
SectionHeaderWithAction(
title = title,
action = actionLabel,
onAction = {
if (mainAllEnqueued) {
onCancelAll()
} else {
onRequestUpdateAll(mainEntries.map { it.key })
}
}
)
}
updateItems(
entries = mainEntries,
keyPrefix = "main",
onNavigateTo = onNavigateTo,
onRequestUpdate = onRequestUpdate,
onCancelUpdate = onCancelUpdate
)
}
items(
items = updateMap.entries.toList(),
key = { it.key.packageName }
) { (update, download) ->
AppUpdateItem(
update = update,
download = download,
onClick = { onNavigateTo(Destination.AppDetails(update.packageName)) },
onLongClick = {
onNavigateTo(Destination.AppMenu(MinimalApp.fromUpdate(update)))
},
onUpdate = { onRequestUpdate(update) },
onCancel = { onCancelUpdate(update.packageName) }
if (approvalEntries.isNotEmpty()) {
item(key = "header_approval") {
SectionHeaderWithAction(
title = stringResource(R.string.updates_approval_header),
subtitle = stringResource(R.string.updates_approval_desc),
action = stringResource(R.string.action_update_all),
onAction = {
onRequestUpdateAll(approvalEntries.map { it.key })
}
)
}
updateItems(
entries = approvalEntries,
keyPrefix = "approval",
onNavigateTo = onNavigateTo,
onRequestUpdate = onRequestUpdate,
onCancelUpdate = onCancelUpdate
)
}
if (incompatibleEntries.isNotEmpty()) {
item(key = "header_incompatible") {
SectionHeaderWithAction(
title = stringResource(R.string.updates_incompatible_header),
subtitle = stringResource(R.string.updates_incompatible_desc)
)
}
updateItems(
entries = incompatibleEntries,
keyPrefix = "incompatible",
onNavigateTo = onNavigateTo,
onRequestUpdate = onRequestUpdate,
onCancelUpdate = onCancelUpdate
)
}
if (ignoredUpdates.isNotEmpty()) {
item(key = "header_ignored") {
SectionHeaderWithAction(
title = stringResource(R.string.updates_ignored_header),
subtitle = stringResource(R.string.updates_ignored_desc)
)
}
items(
items = ignoredUpdates,
key = { "ignored-${it.packageName}" }
) { update ->
AppUpdateItem(
update = update,
onClick = {},
onUnignore = { viewModel.unignore(update.packageName) }
)
}
}
}
}
}
}
}
private fun LazyListScope.updateItems(
entries: List<Map.Entry<Update, Download?>>,
keyPrefix: String,
onNavigateTo: (Destination) -> Unit,
onRequestUpdate: (Update) -> Unit,
onCancelUpdate: (String) -> Unit
) {
items(
items = entries,
key = { "$keyPrefix-${it.key.packageName}" }
) { (update, download) ->
AppUpdateItem(
update = update,
download = download,
onClick = {
onNavigateTo(Destination.AppUpdate(update))
},
onUpdate = { onRequestUpdate(update) },
onCancel = { onCancelUpdate(update.packageName) }
)
}
}

View File

@@ -44,7 +44,12 @@ class DownloadHelper @Inject constructor(
private const val VERSION_CODE = "VERSION_CODE"
}
val downloadsList get() = downloadDao.downloads()
// Single stable StateFlow shared across consumers. Previously a `get()` accessor that
// returned a new StateFlow on every read, which made `collectAsStateWithLifecycle` re-
// collect on every recomposition and briefly replay the `emptyList()` initial value —
// causing the Updates screen's per-item button to flicker between "Update" and "Cancel"
// on every download tick.
val downloadsList = downloadDao.downloads()
.stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), emptyList())
val pagedDownloads get() = downloadDao.pagedDownloads()

View File

@@ -17,6 +17,9 @@ import com.aurora.store.AuroraApp
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.model.UpdateMode
import com.aurora.store.data.room.update.IgnoredUpdate
import com.aurora.store.data.room.update.IgnoredUpdateDao
import com.aurora.store.data.room.update.Update
import com.aurora.store.data.room.update.UpdateDao
import com.aurora.store.data.work.UpdateWorker
import com.aurora.store.util.Preferences
@@ -30,6 +33,7 @@ import java.util.concurrent.TimeUnit.HOURS
import java.util.concurrent.TimeUnit.MINUTES
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -42,6 +46,7 @@ import kotlinx.coroutines.launch
*/
class UpdateHelper @Inject constructor(
private val updateDao: UpdateDao,
private val ignoredUpdateDao: IgnoredUpdateDao,
@ApplicationContext private val context: Context
) {
@@ -86,9 +91,36 @@ class UpdateHelper @Inject constructor(
private val isExtendedUpdateEnabled
get() = Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED)
val updates = updateDao.updates()
private val filteredUpdates = updateDao.updates()
.map { list -> if (!isExtendedUpdateEnabled) list.filter { it.hasValidCert } else list }
.stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), null)
/**
* Updates surfaced to the UI / notifications. Updates muted by the user via
* [ignoreAll] or [ignoreVersion] are filtered out — see [ignoredUpdates] for the
* complementary list.
*/
val updates = combine(filteredUpdates, ignoredUpdateDao.ignoredUpdates()) { list, ignored ->
val byPkg = ignored.associateBy { it.packageName }
list.filterNot { it.isIgnoredBy(byPkg[it.packageName]) }
}.stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), null)
/**
* Updates that the user has chosen to ignore (all-future or version-specific) and
* which currently have a row in the `update` table. Used to render the "Ignored"
* footer section on the Updates screen.
*/
val ignoredUpdates = combine(
filteredUpdates,
ignoredUpdateDao.ignoredUpdates()
) { list, ignored ->
val byPkg = ignored.associateBy { it.packageName }
list.filter { it.isIgnoredBy(byPkg[it.packageName]) }
}.stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), emptyList())
private fun Update.isIgnoredBy(rule: IgnoredUpdate?): Boolean {
if (rule == null) return false
return rule.ignoredVersionCode == null || rule.ignoredVersionCode == versionCode
}
val isCheckingUpdates = WorkManager.getInstance(context)
.getWorkInfosForUniqueWorkFlow(EXPEDITED_UPDATE_WORKER)
@@ -153,6 +185,29 @@ class UpdateHelper @Inject constructor(
updateDao.deleteAll()
}
/**
* Hide all future updates for [packageName] until [unignore] is called.
*/
suspend fun ignoreAll(packageName: String) {
ignoredUpdateDao.upsert(IgnoredUpdate(packageName = packageName))
}
/**
* Hide only [versionCode] for [packageName]; newer versions show up again.
*/
suspend fun ignoreVersion(packageName: String, versionCode: Long) {
ignoredUpdateDao.upsert(
IgnoredUpdate(packageName = packageName, ignoredVersionCode = versionCode)
)
}
/**
* Drop the ignore rule for [packageName], regardless of which kind it was.
*/
suspend fun unignore(packageName: String) {
ignoredUpdateDao.delete(packageName)
}
/**
* Cancels the automated updates check
* @see [UpdateWorker]

View File

@@ -1,36 +0,0 @@
package com.aurora.store.data.model
import android.graphics.Bitmap
import android.os.Parcelable
import com.aurora.gplayapi.data.models.App
import com.aurora.store.data.room.update.Update
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class MinimalApp(
val packageName: String,
val versionName: String,
val versionCode: Long,
val displayName: String,
@IgnoredOnParcel
val icon: Bitmap? = null
) : Parcelable {
companion object {
fun fromApp(app: App): MinimalApp = MinimalApp(
app.packageName,
app.versionName,
app.versionCode,
app.displayName
)
fun fromUpdate(update: Update): MinimalApp = MinimalApp(
update.packageName,
update.versionName,
update.versionCode,
update.displayName
)
}
}

View File

@@ -8,12 +8,14 @@ import com.aurora.store.data.room.download.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.data.room.update.IgnoredUpdate
import com.aurora.store.data.room.update.IgnoredUpdateDao
import com.aurora.store.data.room.update.Update
import com.aurora.store.data.room.update.UpdateDao
@Database(
entities = [Download::class, Favourite::class, Update::class],
version = 6,
entities = [Download::class, Favourite::class, Update::class, IgnoredUpdate::class],
version = 8,
exportSchema = true
)
@TypeConverters(DownloadConverter::class)
@@ -21,4 +23,5 @@ abstract class AuroraDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
abstract fun favouriteDao(): FavouriteDao
abstract fun updateDao(): UpdateDao
abstract fun ignoredUpdateDao(): IgnoredUpdateDao
}

View File

@@ -30,6 +30,14 @@ object MigrationHelper {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom5To6(db)
}
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom6To7(db)
}
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom7To8(db)
}
private const val TAG = "MigrationHelper"
private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
@@ -112,4 +120,43 @@ object MigrationHelper {
database.endTransaction()
}
}
/**
* Add isIncompatible column to update table for flagging updates that cannot be applied
* on the current OS (e.g. system app updates on HyperOS / GrapheneOS).
*/
private fun migrateFrom6To7(database: SupportSQLiteDatabase) {
database.beginTransaction()
try {
database.execSQL(
"ALTER TABLE `update` ADD COLUMN isIncompatible INTEGER NOT NULL DEFAULT 0"
)
database.setTransactionSuccessful()
} catch (exception: Exception) {
Log.e(TAG, "Failed while migrating from database version 6 to 7", exception)
} finally {
database.endTransaction()
}
}
/**
* Add ignored_update table for tracking updates that have been muted by the user.
* A null ignoredVersionCode means all future updates for this package are hidden.
*/
private fun migrateFrom7To8(database: SupportSQLiteDatabase) {
database.beginTransaction()
try {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `ignored_update` (" +
"`packageName` TEXT NOT NULL, " +
"`ignoredVersionCode` INTEGER, " +
"PRIMARY KEY(`packageName`))"
)
database.setTransactionSuccessful()
} catch (exception: Exception) {
Log.e(TAG, "Failed while migrating from database version 7 to 8", exception)
} finally {
database.endTransaction()
}
}
}

View File

@@ -7,9 +7,12 @@ import com.aurora.store.data.room.MigrationHelper.MIGRATION_2_3
import com.aurora.store.data.room.MigrationHelper.MIGRATION_3_4
import com.aurora.store.data.room.MigrationHelper.MIGRATION_4_5
import com.aurora.store.data.room.MigrationHelper.MIGRATION_5_6
import com.aurora.store.data.room.MigrationHelper.MIGRATION_6_7
import com.aurora.store.data.room.MigrationHelper.MIGRATION_7_8
import com.aurora.store.data.room.download.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.data.room.update.IgnoredUpdateDao
import com.aurora.store.data.room.update.UpdateDao
import dagger.Module
import dagger.Provides
@@ -35,7 +38,9 @@ object RoomModule {
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8
)
.addTypeConverter(downloadConverter)
.build()
@@ -50,4 +55,8 @@ object RoomModule {
@Provides
fun providesUpdateDao(auroraDatabase: AuroraDatabase): UpdateDao = auroraDatabase.updateDao()
@Provides
fun providesIgnoredUpdateDao(auroraDatabase: AuroraDatabase): IgnoredUpdateDao =
auroraDatabase.ignoredUpdateDao()
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.update
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Marks an update as ignored by the user.
*
* @property packageName Package whose updates are being ignored.
* @property ignoredVersionCode When `null`, all future updates for this package are hidden.
* When set, only that specific version is hidden — a newer version arriving will show up
* in the Updates list again.
*/
@Entity(tableName = "ignored_update")
data class IgnoredUpdate(
@PrimaryKey
val packageName: String,
val ignoredVersionCode: Long? = null
)

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.room.update
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface IgnoredUpdateDao {
@Query("SELECT * FROM ignored_update")
fun ignoredUpdates(): Flow<List<IgnoredUpdate>>
@Upsert
suspend fun upsert(ignoredUpdate: IgnoredUpdate)
@Query("DELETE FROM ignored_update WHERE packageName = :packageName")
suspend fun delete(packageName: String)
}

View File

@@ -1,11 +1,15 @@
package com.aurora.store.data.room.update
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Parcelable
import androidx.annotation.RequiresApi
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.BuildConfig
import com.aurora.store.data.room.download.SharedLib
import com.aurora.store.util.CertUtil
import com.aurora.store.util.PackageUtil
@@ -29,11 +33,12 @@ data class Update(
val offerType: Int,
var fileList: List<PlayFile>,
val sharedLibs: List<SharedLib>,
val targetSdk: Int = 1
val targetSdk: Int = 1,
val isIncompatible: Boolean = false
) : Parcelable {
companion object {
fun fromApp(context: Context, app: App): Update = Update(
fun fromApp(context: Context, app: App, isIncompatible: Boolean = false): Update = Update(
app.packageName,
app.versionCode,
app.versionName,
@@ -53,7 +58,8 @@ data class Update(
app.offerType,
app.fileList.filterNot { it.url.isBlank() },
app.dependencies.dependentLibraries.map { SharedLib.fromApp(it) },
app.targetSdk
app.targetSdk,
isIncompatible
)
}
@@ -63,4 +69,27 @@ data class Update(
fun isUpToDate(context: Context): Boolean =
PackageUtil.isInstalled(context, packageName, versionCode)
/**
* Update ownership was introduced in Android 14 (API 34) via
* [android.content.pm.PackageInstaller.SessionParams.setRequestUpdateOwnership]; if a
* different installer holds it, the OS shows a transfer-approval dialog at install time.
* Below API 34 the concept does not exist, so this returns false. We also return false when
* no installer has claimed ownership (`updateOwnerPackageName == null`) since no transfer
* prompt is shown in that case.
*/
fun requiresOwnershipTransfer(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return false
if (isSelfUpdate(context)) return false
return updateOwnerOnApi34(context.packageManager)?.let {
it != BuildConfig.APPLICATION_ID
} ?: false
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun updateOwnerOnApi34(packageManager: PackageManager): String? = try {
packageManager.getInstallSourceInfo(packageName).updateOwnerPackageName
} catch (_: PackageManager.NameNotFoundException) {
null
}
}

View File

@@ -7,7 +7,6 @@ package com.aurora.store.data.work
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import androidx.core.content.getSystemService
@@ -20,10 +19,8 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.room.download.Download
import com.aurora.store.util.NotificationUtil
import com.aurora.store.util.PackageUtil.getPackageInfo
import com.aurora.store.util.PathUtil
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -32,7 +29,7 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* An expedited worker to export downloaded or installed apps.
* An expedited worker to export downloaded apps to a user-chosen URI as a zip bundle.
*/
@HiltWorker
class ExportWorker @AssistedInject constructor(
@@ -44,7 +41,6 @@ class ExportWorker @AssistedInject constructor(
private const val TAG = "ExportWorker"
private const val URI = "URI"
private const val IS_DOWNLOAD = "IS_DOWNLOAD"
private const val PACKAGE_NAME = "PACKAGE_NAME"
private const val VERSION_CODE = "VERSION_CODE"
private const val DISPLAY_NAME = "DISPLAY_NAME"
@@ -52,28 +48,6 @@ class ExportWorker @AssistedInject constructor(
private const val NOTIFICATION_ID = 500
private const val NOTIFICATION_ID_FGS = 501
/**
* Exports the installed package to the given URI
* @param app App to export
* @see [ExportWorker]
*/
fun exportInstalledApp(context: Context, app: MinimalApp, uri: Uri) {
val inputData = Data.Builder()
.putBoolean(IS_DOWNLOAD, false)
.putString(URI, uri.toString())
.putString(DISPLAY_NAME, app.displayName)
.putString(PACKAGE_NAME, app.packageName)
.build()
val oneTimeWorkRequest = OneTimeWorkRequestBuilder<ExportWorker>()
.setInputData(inputData)
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.build()
Log.i(TAG, "Exporting ${app.packageName}")
WorkManager.getInstance(context).enqueue(oneTimeWorkRequest)
}
/**
* Exports the given download to the URI
* @param download Download to export
@@ -81,7 +55,6 @@ class ExportWorker @AssistedInject constructor(
*/
fun exportDownloadedApp(context: Context, download: Download, uri: Uri) {
val inputData = Data.Builder()
.putBoolean(IS_DOWNLOAD, download.canInstall(context))
.putString(URI, uri.toString())
.putString(DISPLAY_NAME, download.displayName)
.putString(PACKAGE_NAME, download.packageName)
@@ -101,7 +74,6 @@ class ExportWorker @AssistedInject constructor(
private lateinit var notificationManager: NotificationManager
override suspend fun doWork(): Result {
val isDownload = inputData.getBoolean(IS_DOWNLOAD, false)
val uri = inputData.getString(URI)!!.toUri()
val packageName = inputData.getString(PACKAGE_NAME)
val displayName = inputData.getString(DISPLAY_NAME)
@@ -109,18 +81,14 @@ class ExportWorker @AssistedInject constructor(
notificationManager = context.getSystemService<NotificationManager>()!!
if (packageName.isNullOrEmpty() || isDownload && versionCode == -1L) {
if (packageName.isNullOrEmpty() || versionCode == -1L) {
Log.e(TAG, "Input data is corrupt, bailing out!")
notifyStatus(displayName ?: String(), uri, false)
return Result.failure()
}
try {
if (isDownload) {
copyDownloadedApp(packageName, versionCode, uri)
} else {
copyInstalledApp(packageName, uri)
}
copyDownloadedApp(packageName, versionCode, uri)
notifyStatus(displayName ?: packageName, uri)
} catch (exception: Exception) {
Log.e(TAG, "Failed to export $packageName", exception)
@@ -148,18 +116,6 @@ class ExportWorker @AssistedInject constructor(
)
}
private fun copyInstalledApp(packageName: String, uri: Uri) {
val packageInfo = getPackageInfo(context, packageName, PackageManager.GET_META_DATA)
val fileList: MutableList<File?> = mutableListOf()
fileList.add(File(packageInfo.applicationInfo!!.sourceDir))
packageInfo.applicationInfo!!.splitSourceDirs?.let { splits ->
fileList.addAll(splits.map { File(it) })
}
bundleAllAPKs(fileList.filterNotNull(), uri)
}
private fun copyDownloadedApp(packageName: String, versionCode: Long, uri: Uri) = bundleAllAPKs(
PathUtil.getAppDownloadDir(context, packageName, versionCode).listFiles()!!.toList(),
uri

View File

@@ -2,6 +2,7 @@ package com.aurora.store.data.work
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.util.Log
import androidx.core.content.getSystemService
import androidx.hilt.work.HiltWorker
@@ -9,6 +10,8 @@ import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.aurora.Constants
import com.aurora.extensions.TAG
import com.aurora.extensions.isGrapheneOS
import com.aurora.extensions.isHyperOS
import com.aurora.extensions.isIgnoringBatteryOptimizations
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.AppDetailsHelper
@@ -99,10 +102,14 @@ class UpdateWorker @AssistedInject constructor(
}
try {
val updates = checkUpdates()
val allUpdates = checkUpdates()
.also { updateDao.insertUpdates(it) }
.filter { if (!isExtendedUpdateEnabled) it.hasValidCert else true }
// Incompatible updates (e.g. system app updates on HyperOS / GrapheneOS) are
// surfaced in the UI but excluded from notifications and auto-install.
val updates = allUpdates.filterNot { it.isIncompatible }
if (updates.isEmpty() || updateMode == UpdateMode.CHECK_ONLY) {
Log.i(TAG, "Found ${updates.size} updates")
return Result.success()
@@ -171,6 +178,18 @@ class UpdateWorker @AssistedInject constructor(
}
}.map { it.packageName }
// HyperOS and GrapheneOS block third-party updates of pristine system apps.
val osBlocksSystemAppUpdates = isHyperOS || isGrapheneOS
val pristineSystemPackages: Set<String> = if (osBlocksSystemAppUpdates) {
packages.filter {
val flags = it.applicationInfo?.flags ?: 0
flags and ApplicationInfo.FLAG_SYSTEM != 0 &&
flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP == 0
}.map { it.packageName }.toSet()
} else {
emptySet()
}
val updates = appDetailsHelper.getAppByPackageName(filteredPackages)
.filter { it.displayName.isNotEmpty() }
.filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) }
@@ -178,8 +197,13 @@ class UpdateWorker @AssistedInject constructor(
if (canSelfUpdate) getSelfUpdate()?.let { updates.add(it) }
return@withContext updates.map { Update.fromApp(context, it) }
.sortedBy { it.displayName.lowercase(Locale.getDefault()) }
return@withContext updates.map {
Update.fromApp(
context,
it,
isIncompatible = it.packageName in pristineSystemPackages
)
}.sortedBy { it.displayName.lowercase(Locale.getDefault()) }
}
}

View File

@@ -83,10 +83,13 @@ object CertUtil {
private fun isSignedByFDroid(context: Context, packageName: String): Boolean = try {
getX509Certificates(context, packageName).any { cert ->
cert.subjectDN.name.split(",").associate {
val (left, right) = it.split("=")
left to right
}["O"] == "fdroid.org"
cert.subjectDN.name
.split(",")
.mapNotNull {
val parts = it.split("=", limit = 2)
if (parts.size == 2) parts[0] to parts[1] else null
}
.toMap()["O"] == "fdroid.org"
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to check signing cert for $packageName", exception)

View File

@@ -38,6 +38,7 @@ class UpdatesViewModel @Inject constructor(
val downloadsList get() = downloadHelper.downloadsList
val updates get() = updateHelper.updates
val ignoredUpdates get() = updateHelper.ignoredUpdates
val fetchingUpdates = updateHelper.isCheckingUpdates
@@ -45,13 +46,17 @@ class UpdatesViewModel @Inject constructor(
updateHelper.checkUpdatesNow()
}
fun unignore(packageName: String) {
viewModelScope.launch { updateHelper.unignore(packageName) }
}
fun download(update: Update) {
viewModelScope.launch { downloadHelper.enqueueUpdate(update) }
}
fun downloadAll() {
fun downloadAll(updates: List<Update>) {
viewModelScope.launch {
updates.value?.forEach { downloadHelper.enqueueUpdate(it) }
updates.forEach { downloadHelper.enqueueUpdate(it) }
}
}

View File

@@ -1,23 +0,0 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.viewmodel.sheets
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.providers.BlacklistProvider
import com.aurora.store.data.work.ExportWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class AppMenuViewModel @Inject constructor(val blacklistProvider: BlacklistProvider) : ViewModel() {
fun copyInstalledApp(context: Context, app: MinimalApp, uri: Uri) {
ExportWorker.exportInstalledApp(context, app, uri)
}
}

View File

@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.viewmodel.sheets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aurora.store.data.helper.UpdateHelper
import com.aurora.store.data.providers.BlacklistProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class AppUpdateViewModel @Inject constructor(
val blacklistProvider: BlacklistProvider,
private val updateHelper: UpdateHelper
) : ViewModel() {
fun ignoreAllUpdates(packageName: String) {
viewModelScope.launch { updateHelper.ignoreAll(packageName) }
}
fun ignoreVersion(packageName: String, versionCode: Long) {
viewModelScope.launch { updateHelper.ignoreVersion(packageName, versionCode) }
}
}