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

@@ -0,0 +1,289 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "2fad890b7c37341bc2b26ef023b76124",
"entities": [
{
"tableName": "download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `size` INTEGER NOT NULL, `id` INTEGER NOT NULL, `downloadStatus` TEXT NOT NULL, `progress` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `timeRemaining` INTEGER NOT NULL, `totalFiles` INTEGER NOT NULL, `downloadedFiles` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `downloadedAt` INTEGER NOT NULL, `requiresGMS` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionCode",
"columnName": "versionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "offerType",
"columnName": "offerType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isInstalled",
"columnName": "isInstalled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "downloadStatus",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "speed",
"columnName": "speed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeRemaining",
"columnName": "timeRemaining",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalFiles",
"columnName": "totalFiles",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "downloadedFiles",
"columnName": "downloadedFiles",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fileList",
"columnName": "fileList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sharedLibs",
"columnName": "sharedLibs",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "targetSdk",
"columnName": "targetSdk",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "downloadedAt",
"columnName": "downloadedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "requiresGMS",
"columnName": "requiresGMS",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "favourite",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `added` INTEGER NOT NULL, `mode` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "added",
"columnName": "added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "mode",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "update",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `versionName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `changelog` TEXT NOT NULL, `id` INTEGER NOT NULL, `developerName` TEXT NOT NULL, `size` INTEGER NOT NULL, `updatedOn` TEXT NOT NULL, `hasValidCert` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `isIncompatible` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionCode",
"columnName": "versionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "versionName",
"columnName": "versionName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "changelog",
"columnName": "changelog",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "developerName",
"columnName": "developerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedOn",
"columnName": "updatedOn",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasValidCert",
"columnName": "hasValidCert",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "offerType",
"columnName": "offerType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fileList",
"columnName": "fileList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sharedLibs",
"columnName": "sharedLibs",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "targetSdk",
"columnName": "targetSdk",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isIncompatible",
"columnName": "isIncompatible",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2fad890b7c37341bc2b26ef023b76124')"
]
}
}

View File

@@ -0,0 +1,312 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "e21f5fa9464526f3ddfe0a7f58119d1a",
"entities": [
{
"tableName": "download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `size` INTEGER NOT NULL, `id` INTEGER NOT NULL, `downloadStatus` TEXT NOT NULL, `progress` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `timeRemaining` INTEGER NOT NULL, `totalFiles` INTEGER NOT NULL, `downloadedFiles` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `downloadedAt` INTEGER NOT NULL, `requiresGMS` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionCode",
"columnName": "versionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "offerType",
"columnName": "offerType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isInstalled",
"columnName": "isInstalled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "downloadStatus",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "speed",
"columnName": "speed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeRemaining",
"columnName": "timeRemaining",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalFiles",
"columnName": "totalFiles",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "downloadedFiles",
"columnName": "downloadedFiles",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fileList",
"columnName": "fileList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sharedLibs",
"columnName": "sharedLibs",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "targetSdk",
"columnName": "targetSdk",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "downloadedAt",
"columnName": "downloadedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "requiresGMS",
"columnName": "requiresGMS",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "favourite",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `added` INTEGER NOT NULL, `mode` TEXT NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "added",
"columnName": "added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "mode",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "update",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `versionName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `changelog` TEXT NOT NULL, `id` INTEGER NOT NULL, `developerName` TEXT NOT NULL, `size` INTEGER NOT NULL, `updatedOn` TEXT NOT NULL, `hasValidCert` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `isIncompatible` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionCode",
"columnName": "versionCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "versionName",
"columnName": "versionName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "changelog",
"columnName": "changelog",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "developerName",
"columnName": "developerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedOn",
"columnName": "updatedOn",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasValidCert",
"columnName": "hasValidCert",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "offerType",
"columnName": "offerType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fileList",
"columnName": "fileList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sharedLibs",
"columnName": "sharedLibs",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "targetSdk",
"columnName": "targetSdk",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isIncompatible",
"columnName": "isIncompatible",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
},
{
"tableName": "ignored_update",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoredVersionCode` INTEGER, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ignoredVersionCode",
"columnName": "ignoredVersionCode",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e21f5fa9464526f3ddfe0a7f58119d1a')"
]
}
}

View File

@@ -54,6 +54,15 @@ val isVAndAbove: Boolean
val isMIUI: Boolean val isMIUI: Boolean
get() = !getSystemProperty("ro.miui.ui.version.name").isNullOrBlank() get() = !getSystemProperty("ro.miui.ui.version.name").isNullOrBlank()
val isHyperOS: Boolean
get() = !getSystemProperty("ro.mi.os.version.name").isNullOrBlank() ||
!getSystemProperty("ro.mi.os.version.code").isNullOrBlank()
val isGrapheneOS: Boolean
get() = Build.FINGERPRINT.contains("GrapheneOS", ignoreCase = true) ||
Build.HOST.contains("grapheneos", ignoreCase = true) ||
getSystemProperty("ro.build.flavor")?.contains("grapheneos", ignoreCase = true) == true
val isHuawei: Boolean val isHuawei: Boolean
get() = Build.MANUFACTURER.lowercase(Locale.getDefault()).contains("huawei") || get() = Build.MANUFACTURER.lowercase(Locale.getDefault()).contains("huawei") ||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") || Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") ||

View File

@@ -6,6 +6,7 @@
package com.aurora.store.compose.composable package com.aurora.store.compose.composable
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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 @Composable
fun SectionHeaderWithAction( fun SectionHeaderWithAction(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String, title: String,
action: String, action: String? = null,
subtitle: String? = null,
onAction: () -> Unit = {} onAction: () -> Unit = {}
) { ) {
Row( Row(
@@ -87,16 +90,28 @@ fun SectionHeaderWithAction(
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Column(modifier = Modifier.weight(1f)) {
text = title, Text(
style = MaterialTheme.typography.titleMedium, text = title,
fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium,
maxLines = 1, fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis, maxLines = 1,
modifier = Modifier.weight(1f) overflow = TextOverflow.Ellipsis
) )
TextButton(onClick = onAction) { if (!subtitle.isNullOrBlank()) {
Text(action) 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(), lazyListState: LazyListState = rememberLazyListState(),
onHeaderClick: (StreamCluster) -> Unit = {}, onHeaderClick: (StreamCluster) -> Unit = {},
onAppClick: (App) -> Unit = {}, onAppClick: (App) -> Unit = {},
onAppLongClick: (App) -> Unit = {},
onClusterScrolled: (StreamCluster) -> Unit = {}, onClusterScrolled: (StreamCluster) -> Unit = {},
onScrolledToEnd: () -> Unit = {} onScrolledToEnd: () -> Unit = {}
) { ) {
@@ -118,7 +117,6 @@ fun StreamCarousel(
ClusterRow( ClusterRow(
cluster = cluster, cluster = cluster,
onAppClick = onAppClick, onAppClick = onAppClick,
onAppLongClick = onAppLongClick,
onClusterScrolled = onClusterScrolled onClusterScrolled = onClusterScrolled
) )
} }
@@ -135,7 +133,6 @@ fun StreamCarousel(
internal fun ClusterRow( internal fun ClusterRow(
cluster: StreamCluster, cluster: StreamCluster,
onAppClick: (App) -> Unit = {}, onAppClick: (App) -> Unit = {},
onAppLongClick: (App) -> Unit = {},
onClusterScrolled: (StreamCluster) -> Unit = {} onClusterScrolled: (StreamCluster) -> Unit = {}
) { ) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
@@ -162,8 +159,7 @@ internal fun ClusterRow(
) { _, app -> ) { _, app ->
AppListItem( AppListItem(
app = app, app = app,
onClick = { onAppClick(app) }, onClick = { onAppClick(app) }
onLongClick = { onAppLongClick(app) }
) )
} }
if (cluster.hasNext()) { if (cluster.hasNext()) {

View File

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

View File

@@ -5,9 +5,7 @@
package com.aurora.store.compose.composable.app package com.aurora.store.compose.composable.app
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button 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.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.tooling.preview.PreviewWrapper
@@ -52,9 +39,9 @@ fun AppUpdateItem(
update: Update, update: Update,
download: Download? = null, download: Download? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onUpdate: () -> Unit = {}, onUpdate: () -> Unit = {},
onCancel: () -> Unit = {} onCancel: () -> Unit = {},
onUnignore: (() -> Unit)? = null
) { ) {
val inProgress = download != null && download.status !in DownloadStatus.finished val inProgress = download != null && download.status !in DownloadStatus.finished
val progress = if (download?.status == DownloadStatus.DOWNLOADING) { val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
@@ -62,99 +49,68 @@ fun AppUpdateItem(
} else { } else {
0f 0f
} }
var changelogExpanded by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) { Row(
Row( modifier = modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .clickable(onClick = onClick)
.combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(
.padding( horizontal = dimensionResource(R.dimen.padding_medium),
horizontal = dimensionResource(R.dimen.padding_medium), vertical = dimensionResource(R.dimen.padding_small)
vertical = dimensionResource(R.dimen.padding_small) ),
), verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically ) {
) { Box(modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))) {
Box(modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))) { AnimatedAppIcon(
AnimatedAppIcon( modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), iconUrl = update.iconURL,
iconUrl = update.iconURL, inProgress = inProgress,
inProgress = inProgress, progress = progress
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)) { inProgress -> {
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) {
OutlinedButton(onClick = onCancel) { OutlinedButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel)) Text(stringResource(R.string.action_cancel))
} }
} else { }
else -> {
Button(onClick = onUpdate) { Button(onClick = onUpdate) {
Text(stringResource(R.string.action_update)) 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.Category
import com.aurora.gplayapi.data.models.StreamCluster 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.model.PermissionType
import com.aurora.store.data.room.update.Update
/** /**
* All navigation actions available to composable screens. * All navigation actions available to composable screens.
@@ -20,7 +20,7 @@ sealed class Destination {
data class AppDetails(val packageName: String) : Destination() data class AppDetails(val packageName: String) : Destination()
data class DevProfile(val devId: 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 Search : Destination()
data object Downloads : 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.AppDetails -> backstack.add(Screen.AppDetails(destination.packageName))
is Destination.DevProfile -> backstack.add(Screen.DevProfile(destination.devId)) 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)) is Destination.StreamBrowse -> backstack.add(Screen.StreamBrowse(destination.cluster))
Destination.Search -> backstack.add(Screen.Search) 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.gplayapi.helpers.contracts.TopChartsContract
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Destination
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences
import com.aurora.store.viewmodel.category.CategoryViewModel import com.aurora.store.viewmodel.category.CategoryViewModel
import com.aurora.store.viewmodel.homestream.StreamViewModel import com.aurora.store.viewmodel.homestream.StreamViewModel
@@ -100,7 +99,6 @@ fun AppsGamesScreen(
pageType = pageType, pageType = pageType,
viewModel = streamViewModel, viewModel = streamViewModel,
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
onAppLongClick = { onNavigateTo(Destination.AppMenu(MinimalApp.fromApp(it))) },
onHeaderClick = { onNavigateTo(Destination.StreamBrowse(it)) }, onHeaderClick = { onNavigateTo(Destination.StreamBrowse(it)) },
onClusterScrolled = { cluster -> onClusterScrolled = { cluster ->
streamViewModel.observeCluster(category(pageType), cluster) streamViewModel.observeCluster(category(pageType), cluster)

View File

@@ -24,7 +24,6 @@ internal fun ForYouContent(
pageType: Int, pageType: Int,
viewModel: StreamViewModel, viewModel: StreamViewModel,
onAppClick: (App) -> Unit, onAppClick: (App) -> Unit,
onAppLongClick: (App) -> Unit,
onHeaderClick: (StreamCluster) -> Unit, onHeaderClick: (StreamCluster) -> Unit,
onClusterScrolled: (StreamCluster) -> Unit, onClusterScrolled: (StreamCluster) -> Unit,
onScrolledToEnd: () -> Unit onScrolledToEnd: () -> Unit
@@ -43,7 +42,6 @@ internal fun ForYouContent(
streamBundle = streamBundle?.get(category), streamBundle = streamBundle?.get(category),
onHeaderClick = onHeaderClick, onHeaderClick = onHeaderClick,
onAppClick = onAppClick, onAppClick = onAppClick,
onAppLongClick = onAppLongClick,
onClusterScrolled = onClusterScrolled, onClusterScrolled = onClusterScrolled,
onScrolledToEnd = onScrolledToEnd 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.apps.AppsGamesScreen
import com.aurora.store.compose.ui.commons.MoreSheet import com.aurora.store.compose.ui.commons.MoreSheet
import com.aurora.store.compose.ui.commons.NetworkSheet 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.compose.ui.updates.UpdatesScreen
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.model.NetworkStatus import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.data.model.PermissionType import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted 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 com.aurora.store.viewmodel.all.UpdatesViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -88,12 +88,12 @@ fun MainScreen(
} }
var showMoreSheet by remember { mutableStateOf(false) } 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) } var showNetworkDialog by remember { mutableStateOf(false) }
fun handleNavigation(destination: Destination) { fun handleNavigation(destination: Destination) {
when (destination) { when (destination) {
is Destination.AppMenu -> appMenuTarget = destination.app is Destination.AppUpdate -> appUpdateTarget = destination.update
else -> onNavigateTo(destination) else -> onNavigateTo(destination)
} }
} }
@@ -116,8 +116,15 @@ fun MainScreen(
) )
} }
appMenuTarget?.let { app -> appUpdateTarget?.let { app ->
AppMenuSheet(app = app, onDismiss = { appMenuTarget = null }) AppUpdateSheet(
update = app,
onDismiss = { appUpdateTarget = null },
onNavigateTo = { destination ->
appUpdateTarget = null
onNavigateTo(destination)
}
)
} }
Scaffold( Scaffold(
@@ -192,7 +199,7 @@ fun MainScreen(
when (MainTab.entries[page]) { when (MainTab.entries[page]) {
MainTab.APPS -> AppsGamesScreen( MainTab.APPS -> AppsGamesScreen(
pageType = 0, pageType = 0,
onNavigateTo = ::handleNavigation onNavigateTo = onNavigateTo
) )
MainTab.GAMES -> AppsGamesScreen( MainTab.GAMES -> AppsGamesScreen(
pageType = 1, pageType = 1,
@@ -214,10 +221,10 @@ fun MainScreen(
updatesViewModel.download(update) updatesViewModel.download(update)
} }
}, },
onRequestUpdateAll = { onRequestUpdateAll = { selectedUpdates ->
val needsObb = updatesViewModel.updates.value?.any { val needsObb = selectedUpdates.any {
it.fileList.requiresObbDir() it.fileList.requiresObbDir()
} == true }
if (needsObb && !isGranted(context, PermissionType.STORAGE_MANAGER)) { if (needsObb && !isGranted(context, PermissionType.STORAGE_MANAGER)) {
onNavigateTo( onNavigateTo(
Destination.PermissionRationale( Destination.PermissionRationale(
@@ -225,7 +232,7 @@ fun MainScreen(
) )
) )
} else { } else {
updatesViewModel.downloadAll() updatesViewModel.downloadAll(selectedUpdates)
} }
}, },
onCancelUpdate = { packageName -> 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.Arrangement
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.ShimmerUpdateItem
import com.aurora.store.compose.composable.app.AppUpdateItem import com.aurora.store.compose.composable.app.AppUpdateItem
import com.aurora.store.compose.navigation.Destination 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.data.room.update.Update
import com.aurora.store.viewmodel.all.UpdatesViewModel import com.aurora.store.viewmodel.all.UpdatesViewModel
@@ -33,11 +35,13 @@ fun UpdatesScreen(
viewModel: UpdatesViewModel = hiltViewModel(), viewModel: UpdatesViewModel = hiltViewModel(),
onNavigateTo: (Destination) -> Unit = {}, onNavigateTo: (Destination) -> Unit = {},
onRequestUpdate: (Update) -> Unit = {}, onRequestUpdate: (Update) -> Unit = {},
onRequestUpdateAll: () -> Unit = {}, onRequestUpdateAll: (List<Update>) -> Unit = {},
onCancelUpdate: (String) -> Unit = {}, onCancelUpdate: (String) -> Unit = {},
onCancelAll: () -> Unit = {} onCancelAll: () -> Unit = {}
) { ) {
val context = LocalContext.current
val updates by viewModel.updates.collectAsStateWithLifecycle() val updates by viewModel.updates.collectAsStateWithLifecycle()
val ignoredUpdates by viewModel.ignoredUpdates.collectAsStateWithLifecycle()
val downloads by viewModel.downloadsList.collectAsStateWithLifecycle() val downloads by viewModel.downloadsList.collectAsStateWithLifecycle()
val fetchingUpdates by viewModel.fetchingUpdates.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( PullToRefreshBox(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -80,48 +100,128 @@ fun UpdatesScreen(
} }
else -> { 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy( verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.margin_medium) dimensionResource(R.dimen.margin_medium)
) )
) { ) {
item(key = "header") { if (mainEntries.isNotEmpty()) {
SectionHeaderWithAction( item(key = "header_main") {
title = title, val title = "${mainEntries.size} " + stringResource(
action = actionLabel, if (mainEntries.size == 1) {
onAction = { R.string.update_available
if (allEnqueued) onCancelAll() else onRequestUpdateAll() } 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(), if (approvalEntries.isNotEmpty()) {
key = { it.key.packageName } item(key = "header_approval") {
) { (update, download) -> SectionHeaderWithAction(
AppUpdateItem( title = stringResource(R.string.updates_approval_header),
update = update, subtitle = stringResource(R.string.updates_approval_desc),
download = download, action = stringResource(R.string.action_update_all),
onClick = { onNavigateTo(Destination.AppDetails(update.packageName)) }, onAction = {
onLongClick = { onRequestUpdateAll(approvalEntries.map { it.key })
onNavigateTo(Destination.AppMenu(MinimalApp.fromUpdate(update))) }
}, )
onUpdate = { onRequestUpdate(update) }, }
onCancel = { onCancelUpdate(update.packageName) } 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" 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()) .stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), emptyList())
val pagedDownloads get() = downloadDao.pagedDownloads() 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.BusEvent
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
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.IgnoredUpdateDao
import com.aurora.store.data.room.update.Update
import com.aurora.store.data.room.update.UpdateDao import com.aurora.store.data.room.update.UpdateDao
import com.aurora.store.data.work.UpdateWorker import com.aurora.store.data.work.UpdateWorker
import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences
@@ -30,6 +33,7 @@ import java.util.concurrent.TimeUnit.HOURS
import java.util.concurrent.TimeUnit.MINUTES import java.util.concurrent.TimeUnit.MINUTES
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -42,6 +46,7 @@ import kotlinx.coroutines.launch
*/ */
class UpdateHelper @Inject constructor( class UpdateHelper @Inject constructor(
private val updateDao: UpdateDao, private val updateDao: UpdateDao,
private val ignoredUpdateDao: IgnoredUpdateDao,
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) { ) {
@@ -86,9 +91,36 @@ class UpdateHelper @Inject constructor(
private val isExtendedUpdateEnabled private val isExtendedUpdateEnabled
get() = Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED) 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 } .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) val isCheckingUpdates = WorkManager.getInstance(context)
.getWorkInfosForUniqueWorkFlow(EXPEDITED_UPDATE_WORKER) .getWorkInfosForUniqueWorkFlow(EXPEDITED_UPDATE_WORKER)
@@ -153,6 +185,29 @@ class UpdateHelper @Inject constructor(
updateDao.deleteAll() 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 * Cancels the automated updates check
* @see [UpdateWorker] * @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.download.DownloadDao
import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao 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.Update
import com.aurora.store.data.room.update.UpdateDao import com.aurora.store.data.room.update.UpdateDao
@Database( @Database(
entities = [Download::class, Favourite::class, Update::class], entities = [Download::class, Favourite::class, Update::class, IgnoredUpdate::class],
version = 6, version = 8,
exportSchema = true exportSchema = true
) )
@TypeConverters(DownloadConverter::class) @TypeConverters(DownloadConverter::class)
@@ -21,4 +23,5 @@ abstract class AuroraDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao abstract fun downloadDao(): DownloadDao
abstract fun favouriteDao(): FavouriteDao abstract fun favouriteDao(): FavouriteDao
abstract fun updateDao(): UpdateDao abstract fun updateDao(): UpdateDao
abstract fun ignoredUpdateDao(): IgnoredUpdateDao
} }

View File

@@ -30,6 +30,14 @@ object MigrationHelper {
override fun migrate(db: SupportSQLiteDatabase) = migrateFrom5To6(db) 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 const val TAG = "MigrationHelper"
private fun migrateFrom1To2(database: SupportSQLiteDatabase) { private fun migrateFrom1To2(database: SupportSQLiteDatabase) {
@@ -112,4 +120,43 @@ object MigrationHelper {
database.endTransaction() 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_3_4
import com.aurora.store.data.room.MigrationHelper.MIGRATION_4_5 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_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.DownloadConverter
import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.room.favourite.FavouriteDao 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 com.aurora.store.data.room.update.UpdateDao
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -35,7 +38,9 @@ object RoomModule {
MIGRATION_2_3, MIGRATION_2_3,
MIGRATION_3_4, MIGRATION_3_4,
MIGRATION_4_5, MIGRATION_4_5,
MIGRATION_5_6 MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8
) )
.addTypeConverter(downloadConverter) .addTypeConverter(downloadConverter)
.build() .build()
@@ -50,4 +55,8 @@ object RoomModule {
@Provides @Provides
fun providesUpdateDao(auroraDatabase: AuroraDatabase): UpdateDao = auroraDatabase.updateDao() 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 package com.aurora.store.data.room.update
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.RequiresApi
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.PlayFile import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.BuildConfig
import com.aurora.store.data.room.download.SharedLib import com.aurora.store.data.room.download.SharedLib
import com.aurora.store.util.CertUtil import com.aurora.store.util.CertUtil
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
@@ -29,11 +33,12 @@ data class Update(
val offerType: Int, val offerType: Int,
var fileList: List<PlayFile>, var fileList: List<PlayFile>,
val sharedLibs: List<SharedLib>, val sharedLibs: List<SharedLib>,
val targetSdk: Int = 1 val targetSdk: Int = 1,
val isIncompatible: Boolean = false
) : Parcelable { ) : Parcelable {
companion object { companion object {
fun fromApp(context: Context, app: App): Update = Update( fun fromApp(context: Context, app: App, isIncompatible: Boolean = false): Update = Update(
app.packageName, app.packageName,
app.versionCode, app.versionCode,
app.versionName, app.versionName,
@@ -53,7 +58,8 @@ data class Update(
app.offerType, app.offerType,
app.fileList.filterNot { it.url.isBlank() }, app.fileList.filterNot { it.url.isBlank() },
app.dependencies.dependentLibraries.map { SharedLib.fromApp(it) }, app.dependencies.dependentLibraries.map { SharedLib.fromApp(it) },
app.targetSdk app.targetSdk,
isIncompatible
) )
} }
@@ -63,4 +69,27 @@ data class Update(
fun isUpToDate(context: Context): Boolean = fun isUpToDate(context: Context): Boolean =
PackageUtil.isInstalled(context, packageName, versionCode) 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.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
@@ -20,10 +19,8 @@ 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.store.data.model.MinimalApp
import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download
import com.aurora.store.util.NotificationUtil import com.aurora.store.util.NotificationUtil
import com.aurora.store.util.PackageUtil.getPackageInfo
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
@@ -32,7 +29,7 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream 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 @HiltWorker
class ExportWorker @AssistedInject constructor( class ExportWorker @AssistedInject constructor(
@@ -44,7 +41,6 @@ class ExportWorker @AssistedInject constructor(
private const val TAG = "ExportWorker" private const val TAG = "ExportWorker"
private const val URI = "URI" private const val URI = "URI"
private const val IS_DOWNLOAD = "IS_DOWNLOAD"
private const val PACKAGE_NAME = "PACKAGE_NAME" private const val PACKAGE_NAME = "PACKAGE_NAME"
private const val VERSION_CODE = "VERSION_CODE" private const val VERSION_CODE = "VERSION_CODE"
private const val DISPLAY_NAME = "DISPLAY_NAME" 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 = 500
private const val NOTIFICATION_ID_FGS = 501 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 * Exports the given download to the URI
* @param download Download to export * @param download Download to export
@@ -81,7 +55,6 @@ class ExportWorker @AssistedInject constructor(
*/ */
fun exportDownloadedApp(context: Context, download: Download, uri: Uri) { fun exportDownloadedApp(context: Context, download: Download, uri: Uri) {
val inputData = Data.Builder() val inputData = Data.Builder()
.putBoolean(IS_DOWNLOAD, download.canInstall(context))
.putString(URI, uri.toString()) .putString(URI, uri.toString())
.putString(DISPLAY_NAME, download.displayName) .putString(DISPLAY_NAME, download.displayName)
.putString(PACKAGE_NAME, download.packageName) .putString(PACKAGE_NAME, download.packageName)
@@ -101,7 +74,6 @@ class ExportWorker @AssistedInject constructor(
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val isDownload = inputData.getBoolean(IS_DOWNLOAD, false)
val uri = inputData.getString(URI)!!.toUri() val uri = inputData.getString(URI)!!.toUri()
val packageName = inputData.getString(PACKAGE_NAME) val packageName = inputData.getString(PACKAGE_NAME)
val displayName = inputData.getString(DISPLAY_NAME) val displayName = inputData.getString(DISPLAY_NAME)
@@ -109,18 +81,14 @@ class ExportWorker @AssistedInject constructor(
notificationManager = context.getSystemService<NotificationManager>()!! notificationManager = context.getSystemService<NotificationManager>()!!
if (packageName.isNullOrEmpty() || isDownload && 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) notifyStatus(displayName ?: String(), uri, false)
return Result.failure() return Result.failure()
} }
try { try {
if (isDownload) { copyDownloadedApp(packageName, versionCode, uri)
copyDownloadedApp(packageName, versionCode, uri)
} else {
copyInstalledApp(packageName, 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)
@@ -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( private fun copyDownloadedApp(packageName: String, versionCode: Long, uri: Uri) = bundleAllAPKs(
PathUtil.getAppDownloadDir(context, packageName, versionCode).listFiles()!!.toList(), PathUtil.getAppDownloadDir(context, packageName, versionCode).listFiles()!!.toList(),
uri uri

View File

@@ -2,6 +2,7 @@ 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.ApplicationInfo
import android.util.Log import android.util.Log
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
@@ -9,6 +10,8 @@ import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aurora.Constants import com.aurora.Constants
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.extensions.isGrapheneOS
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
@@ -99,10 +102,14 @@ class UpdateWorker @AssistedInject constructor(
} }
try { try {
val updates = checkUpdates() val allUpdates = checkUpdates()
.also { updateDao.insertUpdates(it) } .also { updateDao.insertUpdates(it) }
.filter { if (!isExtendedUpdateEnabled) it.hasValidCert else true } .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) { if (updates.isEmpty() || updateMode == UpdateMode.CHECK_ONLY) {
Log.i(TAG, "Found ${updates.size} updates") Log.i(TAG, "Found ${updates.size} updates")
return Result.success() return Result.success()
@@ -171,6 +178,18 @@ class UpdateWorker @AssistedInject constructor(
} }
}.map { it.packageName } }.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) val updates = appDetailsHelper.getAppByPackageName(filteredPackages)
.filter { it.displayName.isNotEmpty() } .filter { it.displayName.isNotEmpty() }
.filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) } .filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) }
@@ -178,8 +197,13 @@ class UpdateWorker @AssistedInject constructor(
if (canSelfUpdate) getSelfUpdate()?.let { updates.add(it) } if (canSelfUpdate) getSelfUpdate()?.let { updates.add(it) }
return@withContext updates.map { Update.fromApp(context, it) } return@withContext updates.map {
.sortedBy { it.displayName.lowercase(Locale.getDefault()) } 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 { private fun isSignedByFDroid(context: Context, packageName: String): Boolean = try {
getX509Certificates(context, packageName).any { cert -> getX509Certificates(context, packageName).any { cert ->
cert.subjectDN.name.split(",").associate { cert.subjectDN.name
val (left, right) = it.split("=") .split(",")
left to right .mapNotNull {
}["O"] == "fdroid.org" val parts = it.split("=", limit = 2)
if (parts.size == 2) parts[0] to parts[1] else null
}
.toMap()["O"] == "fdroid.org"
} }
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to check signing cert for $packageName", 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 downloadsList get() = downloadHelper.downloadsList
val updates get() = updateHelper.updates val updates get() = updateHelper.updates
val ignoredUpdates get() = updateHelper.ignoredUpdates
val fetchingUpdates = updateHelper.isCheckingUpdates val fetchingUpdates = updateHelper.isCheckingUpdates
@@ -45,13 +46,17 @@ class UpdatesViewModel @Inject constructor(
updateHelper.checkUpdatesNow() updateHelper.checkUpdatesNow()
} }
fun unignore(packageName: String) {
viewModelScope.launch { updateHelper.unignore(packageName) }
}
fun download(update: Update) { fun download(update: Update) {
viewModelScope.launch { downloadHelper.enqueueUpdate(update) } viewModelScope.launch { downloadHelper.enqueueUpdate(update) }
} }
fun downloadAll() { fun downloadAll(updates: List<Update>) {
viewModelScope.launch { 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) }
}
}

View File

@@ -307,6 +307,16 @@
<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_incompatible_header">Incompatible updates</string>
<string name="updates_incompatible_desc">These system app updates cannot be installed on this OS</string>
<string name="updates_approval_header">Updates requiring approval</string>
<string name="updates_approval_desc">These apps were not installed by Aurora. Updating them prompts to transfer app update ownership.</string>
<string name="updates_app_details">Details</string>
<string name="updates_ignored_header">Ignored</string>
<string name="updates_ignored_desc">These updates are hidden until you unignore them</string>
<string name="action_ignore_all">Ignore all updates</string>
<string name="action_ignore_version">Skip version <xliff:g id="version_name">%1$s</xliff:g></string>
<string name="action_unignore">Unignore</string>
<string name="error">An error occurred!</string> <string name="error">An error occurred!</string>
<string name="menu">Menu</string> <string name="menu">Menu</string>
<string name="expand">Expand</string> <string name="expand">Expand</string>