diff --git a/app/schemas/com.aurora.store.data.room.AuroraDatabase/7.json b/app/schemas/com.aurora.store.data.room.AuroraDatabase/7.json new file mode 100644 index 000000000..a011f8d89 --- /dev/null +++ b/app/schemas/com.aurora.store.data.room.AuroraDatabase/7.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.aurora.store.data.room.AuroraDatabase/8.json b/app/schemas/com.aurora.store.data.room.AuroraDatabase/8.json new file mode 100644 index 000000000..0810dcc35 --- /dev/null +++ b/app/schemas/com.aurora.store.data.room.AuroraDatabase/8.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aurora/extensions/Platform.kt b/app/src/main/java/com/aurora/extensions/Platform.kt index 4d28213a8..abedc307b 100644 --- a/app/src/main/java/com/aurora/extensions/Platform.kt +++ b/app/src/main/java/com/aurora/extensions/Platform.kt @@ -54,6 +54,15 @@ val isVAndAbove: Boolean val isMIUI: Boolean 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 get() = Build.MANUFACTURER.lowercase(Locale.getDefault()).contains("huawei") || Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") || diff --git a/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt b/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt index 960aaf6d8..27693c53a 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt @@ -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) + } } } } diff --git a/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt b/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt index e2d46a134..1c810db64 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt @@ -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()) { diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt index 73f4057d9..3184b98fc 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt @@ -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( diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt index d1dfd06c5..71de19aba 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt @@ -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)) - ) - } - } } } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt index 633597f05..3f824027e 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt @@ -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() diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index d379dbf4b..773f09c03 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -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) diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/AppsGamesScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/AppsGamesScreen.kt index 83451016c..22a248f2c 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/apps/AppsGamesScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/AppsGamesScreen.kt @@ -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) diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt index 785580aed..24daf6dc1 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt @@ -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 ) diff --git a/app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt index 0ea77714c..1976ab6fb 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt @@ -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(null) } + var appUpdateTarget by remember { mutableStateOf(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 -> diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/AppMenuSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/AppMenuSheet.kt deleted file mode 100644 index 8a2ae2caa..000000000 --- a/app/src/main/java/com/aurora/store/compose/ui/sheets/AppMenuSheet.kt +++ /dev/null @@ -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()) - } -} diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt new file mode 100644 index 000000000..db12afc2e --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt @@ -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) + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt index 34e3425b7..41f3931cc 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt @@ -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) -> 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>() + val approval = mutableListOf>() + val incompatible = mutableListOf>() + 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>, + 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) } + ) + } +} diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index b08f680e8..d3c201c54 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -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() diff --git a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt index 37a6c7fa0..bb7f27db1 100644 --- a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt @@ -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] diff --git a/app/src/main/java/com/aurora/store/data/model/MinimalApp.kt b/app/src/main/java/com/aurora/store/data/model/MinimalApp.kt deleted file mode 100644 index 41519abef..000000000 --- a/app/src/main/java/com/aurora/store/data/model/MinimalApp.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt index 298ad090e..5bd70fda5 100644 --- a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt +++ b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt index fa2b05c90..67524dfd5 100644 --- a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt +++ b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt @@ -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() + } + } } diff --git a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt index 05ecb723e..a73329630 100644 --- a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt +++ b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt @@ -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() } diff --git a/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdate.kt b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdate.kt new file mode 100644 index 000000000..87a62662e --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdate.kt @@ -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 +) diff --git a/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdateDao.kt b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdateDao.kt new file mode 100644 index 000000000..765a2c1b4 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdateDao.kt @@ -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> + + @Upsert + suspend fun upsert(ignoredUpdate: IgnoredUpdate) + + @Query("DELETE FROM ignored_update WHERE packageName = :packageName") + suspend fun delete(packageName: String) +} diff --git a/app/src/main/java/com/aurora/store/data/room/update/Update.kt b/app/src/main/java/com/aurora/store/data/room/update/Update.kt index bec3e8e76..fd5b9f38f 100644 --- a/app/src/main/java/com/aurora/store/data/room/update/Update.kt +++ b/app/src/main/java/com/aurora/store/data/room/update/Update.kt @@ -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, val sharedLibs: List, - 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 + } } diff --git a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt index fbf66a286..21740d6ac 100644 --- a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt @@ -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() - .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()!! - 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 = 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 diff --git a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt index ff92e1166..f0bbd8e97 100644 --- a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt @@ -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 = 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()) } } } diff --git a/app/src/main/java/com/aurora/store/util/CertUtil.kt b/app/src/main/java/com/aurora/store/util/CertUtil.kt index 1db0d0669..8651cd732 100644 --- a/app/src/main/java/com/aurora/store/util/CertUtil.kt +++ b/app/src/main/java/com/aurora/store/util/CertUtil.kt @@ -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) diff --git a/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt index ec1027871..8b1ef0c6b 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt @@ -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) { viewModelScope.launch { - updates.value?.forEach { downloadHelper.enqueueUpdate(it) } + updates.forEach { downloadHelper.enqueueUpdate(it) } } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/sheets/AppMenuViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/sheets/AppMenuViewModel.kt deleted file mode 100644 index 920c3bc8a..000000000 --- a/app/src/main/java/com/aurora/store/viewmodel/sheets/AppMenuViewModel.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/com/aurora/store/viewmodel/sheets/AppUpdateViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/sheets/AppUpdateViewModel.kt new file mode 100644 index 000000000..d54856445 --- /dev/null +++ b/app/src/main/java/com/aurora/store/viewmodel/sheets/AppUpdateViewModel.kt @@ -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) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 061af3343..ef5a4267d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -307,6 +307,16 @@ Enables background app download update available updates available + Incompatible updates + These system app updates cannot be installed on this OS + Updates requiring approval + These apps were not installed by Aurora. Updating them prompts to transfer app update ownership. + Details + Ignored + These updates are hidden until you unignore them + Ignore all updates + Skip version %1$s + Unignore An error occurred! Menu Expand