From f2034f3a412988409f705cdc1eba26742dc647e7 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 11 Apr 2026 07:04:11 +0500 Subject: [PATCH 1/3] feat: implement preferred APK variant tracking and selection - Introduce `AssetVariant` utility to extract and match stable variant identifiers (e.g., `arm64-v8a`, `universal`) from GitHub asset filenames. - Add `preferredAssetVariant` and `preferredVariantStale` fields to the `installed_apps` database table (Migration 10 to 11). - Implement a `VariantPickerDialog` to allow users to manually select or reset their preferred APK variant. - Update the update-checker logic to prioritize the user's preferred variant and flag it as "stale" if a match is no longer found in new releases. - Surface variant status in the UI, including a "Variant changed" warning and inline variant labels in the apps list. - Update the export/import schema to version 3 to persist preferred variant settings across devices. - Provide localized strings for the variant picker and status hints across all supported languages. --- .../11.json | 619 ++++++++++++++++++ .../core/data/local/db/initDatabase.kt | 2 + .../local/db/migrations/MIGRATION_10_11.kt | 26 + .../rainxch/core/data/local/db/AppDatabase.kt | 2 +- .../core/data/local/db/dao/InstalledAppDao.kt | 36 + .../local/db/entities/InstalledAppEntity.kt | 20 + .../core/data/mappers/InstalledAppsMappers.kt | 4 + .../repository/InstalledAppsRepositoryImpl.kt | 72 +- .../rainxch/core/domain/model/ExportedApp.kt | 14 +- .../rainxch/core/domain/model/InstalledApp.kt | 15 + .../repository/InstalledAppsRepository.kt | 15 + .../rainxch/core/domain/util/AssetVariant.kt | 84 +++ .../composeResources/values-ar/strings-ar.xml | 14 + .../composeResources/values-bn/strings-bn.xml | 14 + .../composeResources/values-es/strings-es.xml | 14 + .../composeResources/values-fr/strings-fr.xml | 14 + .../composeResources/values-hi/strings-hi.xml | 14 + .../composeResources/values-it/strings-it.xml | 14 + .../composeResources/values-ja/strings-ja.xml | 14 + .../composeResources/values-ko/strings-ko.xml | 14 + .../composeResources/values-pl/strings-pl.xml | 14 + .../composeResources/values-ru/strings-ru.xml | 14 + .../composeResources/values-tr/strings-tr.xml | 14 + .../values-zh-rCN/strings-zh-rCN.xml | 14 + .../composeResources/values/strings.xml | 14 + .../data/repository/AppsRepositoryImpl.kt | 18 +- .../apps/domain/repository/AppsRepository.kt | 22 + .../rainxch/apps/presentation/AppsAction.kt | 9 + .../zed/rainxch/apps/presentation/AppsRoot.kt | 37 ++ .../rainxch/apps/presentation/AppsState.kt | 14 + .../apps/presentation/AppsViewModel.kt | 162 ++++- .../AdvancedAppSettingsBottomSheet.kt | 81 ++- .../components/VariantPickerDialog.kt | 308 +++++++++ .../mappers/InstalledAppMapper.kt | 4 + .../apps/presentation/model/InstalledAppUi.kt | 2 + .../details/presentation/DetailsViewModel.kt | 48 +- 36 files changed, 1779 insertions(+), 17 deletions(-) create mode 100644 core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt diff --git a/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json new file mode 100644 index 00000000..d6afadaf --- /dev/null +++ b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json @@ -0,0 +1,619 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "4ef44cac070a165197c60c485f778601", + "entities": [ + { + "tableName": "installed_apps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `signingFingerprint` TEXT, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, `latestReleasePublishedAt` TEXT, `includePreReleases` INTEGER NOT NULL, `assetFilterRegex` TEXT, `fallbackToOlderReleases` INTEGER NOT NULL, `preferredAssetVariant` TEXT, `preferredVariantStale` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwnerAvatarUrl", + "columnName": "repoOwnerAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoDescription", + "columnName": "repoDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryLanguage", + "columnName": "primaryLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "repoUrl", + "columnName": "repoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedVersion", + "columnName": "installedVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedAssetName", + "columnName": "installedAssetName", + "affinity": "TEXT" + }, + { + "fieldPath": "installedAssetUrl", + "columnName": "installedAssetUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "latestVersion", + "columnName": "latestVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "latestAssetName", + "columnName": "latestAssetName", + "affinity": "TEXT" + }, + { + "fieldPath": "latestAssetUrl", + "columnName": "latestAssetUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "latestAssetSize", + "columnName": "latestAssetSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "appName", + "columnName": "appName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installSource", + "columnName": "installSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signingFingerprint", + "columnName": "signingFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "installedAt", + "columnName": "installedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "lastCheckedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUpdateAvailable", + "columnName": "isUpdateAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateCheckEnabled", + "columnName": "updateCheckEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "releaseNotes", + "affinity": "TEXT" + }, + { + "fieldPath": "systemArchitecture", + "columnName": "systemArchitecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileExtension", + "columnName": "fileExtension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPendingInstall", + "columnName": "isPendingInstall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedVersionName", + "columnName": "installedVersionName", + "affinity": "TEXT" + }, + { + "fieldPath": "installedVersionCode", + "columnName": "installedVersionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestVersionName", + "columnName": "latestVersionName", + "affinity": "TEXT" + }, + { + "fieldPath": "latestVersionCode", + "columnName": "latestVersionCode", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestReleasePublishedAt", + "columnName": "latestReleasePublishedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "includePreReleases", + "columnName": "includePreReleases", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assetFilterRegex", + "columnName": "assetFilterRegex", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackToOlderReleases", + "columnName": "fallbackToOlderReleases", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "preferredAssetVariant", + "columnName": "preferredAssetVariant", + "affinity": "TEXT" + }, + { + "fieldPath": "preferredVariantStale", + "columnName": "preferredVariantStale", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "favorite_repos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwnerAvatarUrl", + "columnName": "repoOwnerAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoDescription", + "columnName": "repoDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryLanguage", + "columnName": "primaryLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "repoUrl", + "columnName": "repoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "isInstalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedPackageName", + "columnName": "installedPackageName", + "affinity": "TEXT" + }, + { + "fieldPath": "latestVersion", + "columnName": "latestVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "latestReleaseUrl", + "columnName": "latestReleaseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId" + ] + } + }, + { + "tableName": "update_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `appName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoName` TEXT NOT NULL, `fromVersion` TEXT NOT NULL, `toVersion` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `updateSource` TEXT NOT NULL, `success` INTEGER NOT NULL, `errorMessage` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "appName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromVersion", + "columnName": "fromVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toVersion", + "columnName": "toVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateSource", + "columnName": "updateSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "success", + "columnName": "success", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "starred_repos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `stargazersCount` INTEGER NOT NULL, `forksCount` INTEGER NOT NULL, `openIssuesCount` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `installedPackageName` TEXT, `latestVersion` TEXT, `latestReleaseUrl` TEXT, `starredAt` INTEGER, `addedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwnerAvatarUrl", + "columnName": "repoOwnerAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoDescription", + "columnName": "repoDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryLanguage", + "columnName": "primaryLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "repoUrl", + "columnName": "repoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stargazersCount", + "columnName": "stargazersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forksCount", + "columnName": "forksCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openIssuesCount", + "columnName": "openIssuesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "isInstalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedPackageName", + "columnName": "installedPackageName", + "affinity": "TEXT" + }, + { + "fieldPath": "latestVersion", + "columnName": "latestVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "latestReleaseUrl", + "columnName": "latestReleaseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "starredAt", + "columnName": "starredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId" + ] + } + }, + { + "tableName": "cache_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `jsonData` TEXT NOT NULL, `cachedAt` INTEGER NOT NULL, `expiresAt` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonData", + "columnName": "jsonData", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "seen_repos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `seenAt` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwnerAvatarUrl", + "columnName": "repoOwnerAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoDescription", + "columnName": "repoDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryLanguage", + "columnName": "primaryLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "repoUrl", + "columnName": "repoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seenAt", + "columnName": "seenAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId" + ] + } + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `searchedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchedAt", + "columnName": "searchedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + } + ], + "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, '4ef44cac070a165197c60c485f778601')" + ] + } +} \ No newline at end of file diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt index 576d9a0c..350a32a8 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt @@ -12,6 +12,7 @@ import zed.rainxch.core.data.local.db.migrations.MIGRATION_6_7 import zed.rainxch.core.data.local.db.migrations.MIGRATION_7_8 import zed.rainxch.core.data.local.db.migrations.MIGRATION_8_9 import zed.rainxch.core.data.local.db.migrations.MIGRATION_9_10 +import zed.rainxch.core.data.local.db.migrations.MIGRATION_10_11 fun initDatabase(context: Context): AppDatabase { val appContext = context.applicationContext @@ -31,5 +32,6 @@ fun initDatabase(context: Context): AppDatabase { MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, + MIGRATION_10_11, ).build() } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt new file mode 100644 index 00000000..17edc353 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt @@ -0,0 +1,26 @@ +package zed.rainxch.core.data.local.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Adds per-app preferred-variant tracking to the installed_apps table: + * - preferredAssetVariant: stable identifier (e.g. "arm64-v8a") for the + * asset the user wants to install. Survives version bumps because it's + * derived from the part of the filename that doesn't change. + * - preferredVariantStale: flipped to true by checkForUpdates when the + * persisted variant cannot be matched in a fresh release; the UI then + * prompts the user to pick again. + * + * Both columns default to safe "no preference" values so existing rows + * keep their current auto-pick behaviour. + */ +val MIGRATION_10_11 = + object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE installed_apps ADD COLUMN preferredAssetVariant TEXT") + db.execSQL( + "ALTER TABLE installed_apps ADD COLUMN preferredVariantStale INTEGER NOT NULL DEFAULT 0", + ) + } + } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt index 81d1a471..8ea3f272 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt @@ -27,7 +27,7 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity SeenRepoEntity::class, SearchHistoryEntity::class, ], - version = 10, + version = 11, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt index 348d5a3c..0c8e55c4 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt @@ -88,6 +88,42 @@ interface InstalledAppDao { fallback: Boolean, ) + /** + * Sets the user's preferred asset variant. Always clears the + * "stale" flag in the same write because the user has just made an + * explicit choice — whatever was stored before is no longer stale, + * even if the new variant is the same value. + */ + @Query( + """ + UPDATE installed_apps + SET preferredAssetVariant = :variant, + preferredVariantStale = 0 + WHERE packageName = :packageName + """, + ) + suspend fun updatePreferredVariant( + packageName: String, + variant: String?, + ) + + /** + * Sets `preferredVariantStale` for [packageName]. Used by + * `checkForUpdates` when the persisted variant cannot be matched + * against the assets in a fresh release. + */ + @Query( + """ + UPDATE installed_apps + SET preferredVariantStale = :stale + WHERE packageName = :packageName + """, + ) + suspend fun updateVariantStaleness( + packageName: String, + stale: Boolean, + ) + @Query("UPDATE installed_apps SET lastCheckedAt = :timestamp WHERE packageName = :packageName") suspend fun updateLastChecked( packageName: String, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt index 68d8969a..61bc121a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt @@ -52,4 +52,24 @@ data class InstalledAppEntity( * monorepos where the latest release is for a *different* app. */ val fallbackToOlderReleases: Boolean = false, + /** + * Stable identifier for the asset variant the user wants to track — + * for example `arm64-v8a`, `universal`, or `x86_64`. Derived from the + * picked asset filename by stripping the version segment, so it + * survives release-to-release version bumps. + * + * `null` means "use the platform installer's automatic picker" + * (today's behaviour). When non-null, [checkForUpdates] resolves the + * matching asset on every check; if no asset in the new release + * matches the variant, [preferredVariantStale] is flipped to true. + */ + val preferredAssetVariant: String? = null, + /** + * Set to true by the update checker when the persisted + * [preferredAssetVariant] cannot be found in the latest release's + * assets — typically because the maintainer renamed or restructured + * the artefacts. The UI surfaces this with a "variant changed — + * pick again" prompt and clears it once the user picks a new variant. + */ + val preferredVariantStale: Boolean = false, ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt index 818cb903..06108948 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt @@ -40,6 +40,8 @@ fun InstalledApp.toEntity(): InstalledAppEntity = includePreReleases = includePreReleases, assetFilterRegex = assetFilterRegex, fallbackToOlderReleases = fallbackToOlderReleases, + preferredAssetVariant = preferredAssetVariant, + preferredVariantStale = preferredVariantStale, ) fun InstalledAppEntity.toDomain(): InstalledApp = @@ -79,4 +81,6 @@ fun InstalledAppEntity.toDomain(): InstalledApp = includePreReleases = includePreReleases, assetFilterRegex = assetFilterRegex, fallbackToOlderReleases = fallbackToOlderReleases, + preferredAssetVariant = preferredAssetVariant, + preferredVariantStale = preferredVariantStale, ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 20080c06..9351641c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -28,6 +28,7 @@ import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.MatchingPreview import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.util.AssetFilter +import zed.rainxch.core.domain.util.AssetVariant class InstalledAppsRepositoryImpl( private val database: AppDatabase, @@ -132,18 +133,28 @@ class InstalledAppsRepositoryImpl( * Result of [resolveTrackedRelease] — a candidate release plus the asset * the installer should download for it. `null` when no release in the * window contains a usable asset (after filter + arch matching). + * + * [variantWasLost] is true when the user has a [InstalledApp.preferredAssetVariant] + * set but none of this release's assets matched it. The caller flips + * `preferredVariantStale` based on this so the UI can prompt the user + * to pick a new variant. */ private data class ResolvedRelease( val release: GithubRelease, val primaryAsset: GithubAsset, + val variantWasLost: Boolean, ) /** * Walks [releases] (already in newest-first order) and returns the first * release whose installable asset list — after applying [filter] — yields - * a primary asset for the current architecture. When [filter] is null, - * only the first release in the window is considered: this preserves the - * pre-existing behaviour for apps that don't track a monorepo. + * a usable asset. The picker tries, in order: + * 1. The user's [preferredVariant] (if set) + * 2. The platform installer's architecture-aware auto-pick + * + * When [filter] is null, only the first release in the window is + * considered: this preserves the pre-existing behaviour for apps that + * don't track a monorepo. * * When [filter] is non-null and [fallbackToOlderReleases] is false, the * walker still only inspects the first release. The semantics are: @@ -155,6 +166,7 @@ class InstalledAppsRepositoryImpl( releases: List, filter: AssetFilter?, fallbackToOlderReleases: Boolean, + preferredVariant: String?, ): ResolvedRelease? { if (releases.isEmpty()) return null @@ -173,8 +185,20 @@ class InstalledAppsRepositoryImpl( else installableForPlatform.filter { filter.matches(it.name) } if (installableForApp.isEmpty()) continue - val primary = installer.choosePrimaryAsset(installableForApp) ?: continue - return ResolvedRelease(release, primary) + + // Variant resolution: try the user's pinned variant first. + // Falling back to the auto-picker is intentional — we'd + // rather hand the user a working install than block updates, + // and the caller will mark `variantWasLost` so the UI can + // surface the discrepancy. + val variantMatch = + AssetVariant.resolvePreferredAsset(installableForApp, preferredVariant) + val primary = variantMatch + ?: installer.choosePrimaryAsset(installableForApp) + ?: continue + val variantWasLost = preferredVariant != null && variantMatch == null + + return ResolvedRelease(release, primary, variantWasLost) } return null @@ -216,6 +240,7 @@ class InstalledAppsRepositoryImpl( releases = releases, filter = compiledFilter, fallbackToOlderReleases = app.fallbackToOlderReleases, + preferredVariant = app.preferredAssetVariant, ) if (resolved == null) { @@ -230,7 +255,7 @@ class InstalledAppsRepositoryImpl( return false } - val (matchedRelease, primaryAsset) = resolved + val (matchedRelease, primaryAsset, variantWasLost) = resolved val normalizedInstalledTag = normalizeVersion(app.installedVersion) val normalizedLatestTag = normalizeVersion(matchedRelease.tagName) @@ -246,7 +271,7 @@ class InstalledAppsRepositoryImpl( "installedTag=${app.installedVersion}, " + "matchedTag=${matchedRelease.tagName}, " + "matchedAsset=${primaryAsset.name}, " + - "isUpdate=$isUpdateAvailable" + "isUpdate=$isUpdateAvailable, variantLost=$variantWasLost" } installedAppsDao.updateVersionInfo( @@ -263,6 +288,14 @@ class InstalledAppsRepositoryImpl( latestReleasePublishedAt = matchedRelease.publishedAt, ) + // Sync the staleness flag with what the resolver actually + // observed: flip on when the user's pinned variant has + // disappeared from the latest matching release, flip off + // (and only when previously set) when it's back in business. + if (variantWasLost != app.preferredVariantStale) { + installedAppsDao.updateVariantStaleness(packageName, variantWasLost) + } + return isUpdateAvailable } catch (e: Exception) { Logger.e { "Failed to check updates for $packageName: ${e.message}" } @@ -382,6 +415,31 @@ class InstalledAppsRepositoryImpl( } } + override suspend fun setPreferredVariant( + packageName: String, + variant: String?, + ) { + val normalized = variant?.trim()?.takeIf { it.isNotEmpty() } + installedAppsDao.updatePreferredVariant( + packageName = packageName, + variant = normalized, + ) + + // Re-run the update check so cached `latestAsset*` columns point + // at the variant the user just chose. Failures here are + // non-fatal: persistence is the authoritative step. + try { + checkForUpdates(packageName) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w { + "Saved new variant for $packageName but immediate " + + "re-check failed: ${e.message}" + } + } + } + override suspend fun previewMatchingAssets( owner: String, repo: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt index 76f94a94..a85fe1d5 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt @@ -12,16 +12,22 @@ data class ExportedApp( // old v1 JSON files decoding without changes. val assetFilterRegex: String? = null, val fallbackToOlderReleases: Boolean = false, + // Preferred-variant tracking (added in export schema v3). Defaults + // keep older exports decoding without changes. + val preferredAssetVariant: String? = null, ) @Serializable data class ExportedAppList( /** - * Export schema version. Bumped to 2 when monorepo fields were added - * to [ExportedApp]. Older v1 exports still decode correctly because - * the new fields have safe defaults. + * Export schema version. + * - v2: added [ExportedApp.assetFilterRegex] / [ExportedApp.fallbackToOlderReleases] + * - v3: added [ExportedApp.preferredAssetVariant] + * + * All older versions still decode correctly because the new fields + * have safe defaults. */ - val version: Int = 2, + val version: Int = 3, val exportedAt: Long = 0L, val apps: List = emptyList(), ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt index 673c03dc..e4cee06e 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt @@ -47,4 +47,19 @@ data class InstalledApp( * where the latest release belongs to a sibling app. */ val fallbackToOlderReleases: Boolean = false, + /** + * Stable identifier for the asset variant (e.g. `arm64-v8a`, + * `universal`) that the user has chosen to track. Derived from the + * picked asset filename's tail (everything after the version) so it + * survives version bumps. `null` means "auto-pick by architecture". + */ + val preferredAssetVariant: String? = null, + /** + * Set when the update checker can't find an asset matching + * [preferredAssetVariant] in a fresh release — typically because the + * maintainer renamed or restructured the artefacts. The UI shows a + * "variant changed" prompt; the flag is cleared once the user picks + * a new variant. + */ + val preferredVariantStale: Boolean = false, ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt index 483fb8cf..74437d1e 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt @@ -65,6 +65,21 @@ interface InstalledAppsRepository { fallbackToOlderReleases: Boolean, ) + /** + * Persists the user's preferred asset variant tag for [packageName] + * (or `null` to fall back to the platform's auto-picker). Always + * clears the `preferredVariantStale` flag in the same write because + * the user has just made an explicit choice. + * + * Implementations should re-check the app for updates immediately so + * the cached `latestAsset*` fields point at the variant the user + * just selected, without waiting for the next periodic worker. + */ + suspend fun setPreferredVariant( + packageName: String, + variant: String?, + ) + /** * Dry-run helper for the per-app advanced settings sheet. Fetches a * window of releases for [owner]/[repo] (honouring [includePreReleases]) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt new file mode 100644 index 00000000..dd9fec0f --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt @@ -0,0 +1,84 @@ +package zed.rainxch.core.domain.util + +import zed.rainxch.core.domain.model.GithubAsset + +/** + * Stable identifier extracted from a GitHub release asset filename — the + * tail that remains constant across releases (architecture, packaging + * flavour, etc.). The "preferred variant" feature uses these to remember + * which APK out of a multi-asset release the user wants installed, even + * as version numbers in the filename change from one release to the next. + * + * Examples: + * `ente-auth-3.2.5-arm64-v8a.apk` → `arm64-v8a` + * `myapp-v1.2.3-universal.apk` → `universal` + * `App_2.0.0_x86_64.apk` → `x86_64` + * `bestapp-1.0.0.apk` → `""` (empty: no variant in name) + * `no-version-here.apk` → `null` (no version anchor at all) + * + * Empty string and `null` are different: empty means "we found a version + * but nothing came after it" (so the asset has no variant) — apps with a + * single-asset release land here. `null` means "the filename has no + * version-looking segment we can anchor on" — likely a non-standard name + * we shouldn't try to match against. + */ +object AssetVariant { + /** + * Matches the FIRST version-looking segment in a filename: an + * optional `v`/`V`, one or more digits, and any number of dotted + * digit groups after that. The leading character is required to be a + * separator so we don't false-match on names like `app2-installer`. + */ + private val VERSION_SEGMENT = + Regex("[-_ ]v?\\d+(?:\\.\\d+)*", RegexOption.IGNORE_CASE) + + private val LEADING_SEPARATORS = charArrayOf('-', '_', ' ', '.') + + fun extract(assetName: String): String? { + val withoutExt = assetName.substringBeforeLast('.') + val match = VERSION_SEGMENT.find(withoutExt) ?: return null + // Take everything after the matched version segment, drop any + // leading separators so the result is the bare variant tag. + val tail = + withoutExt + .substring(match.range.last + 1) + .trimStart(*LEADING_SEPARATORS) + .trim() + return tail + } + + /** + * Returns the asset whose extracted variant equals [preferredVariant], + * or `null` if either no preference is set, the preference is blank, + * or no asset matches. Comparison is case-insensitive because some + * maintainers flip casing release-over-release (`Arm64-V8a` vs + * `arm64-v8a`). + */ + fun resolvePreferredAsset( + assets: List, + preferredVariant: String?, + ): GithubAsset? { + val target = preferredVariant?.trim()?.takeIf { it.isNotBlank() } ?: return null + return assets.firstOrNull { asset -> + extract(asset.name)?.equals(target, ignoreCase = true) == true + } + } + + /** + * Pulls the variant tag out of a sample asset filename and returns + * it normalised, or `null` when the name doesn't carry a meaningful + * variant. Skips the work entirely when [siblingAssetCount] is 1 or 0 + * because single-asset releases have nothing to remember. + * + * Single-asset releases and "no variant suffix" filenames both return + * `null` rather than the empty string — there's nothing to pin. + */ + fun deriveFromPickedAsset( + pickedAssetName: String, + siblingAssetCount: Int, + ): String? { + if (siblingAssetCount <= 1) return null + val variant = extract(pickedAssetName) ?: return null + return variant.takeIf { it.isNotEmpty() } + } +} diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 7e73c50e..4c35bea2 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -658,4 +658,18 @@ مطابقة في %1$s · %2$d أصل مطابقة في %1$s · %2$d أصل + + + النوع المفضل + اختر نوع APK الذي يجب تثبيته للتحديثات. يتم تذكر الاختيار عبر الإصدارات. + لا توجد أصول قابلة للتثبيت في أحدث إصدار. + تعذر تحميل الأنواع. تحقق من اتصالك وحاول مرة أخرى. + تعذر حفظ اختيارك. حاول مرة أخرى. + تغير النوع — اختر مرة أخرى + كان: %1$s + تلقائي + دع GitHub Store يختار أفضل نوع لجهازك + مثبت على: %1$s + تغير النوع — انقر تحديث للاختيار مرة أخرى + النوع: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 37459469..fd475d08 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -657,4 +657,18 @@ %1$s-এ মিলেছে · %2$d অ্যাসেট %1$s-এ মিলেছে · %2$d অ্যাসেট + + + পছন্দের ভ্যারিয়েন্ট + আপডেটের জন্য কোন APK ভ্যারিয়েন্ট ইনস্টল হবে তা বেছে নিন। পছন্দ রিলিজ জুড়ে মনে রাখা হয়। + সর্বশেষ রিলিজে কোনো ইনস্টলযোগ্য অ্যাসেট নেই। + ভ্যারিয়েন্ট লোড করা যায়নি। আপনার সংযোগ যাচাই করে আবার চেষ্টা করুন। + আপনার পছন্দ সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন। + ভ্যারিয়েন্ট পরিবর্তিত — আবার বেছে নিন + ছিল: %1$s + স্বয়ংক্রিয় + GitHub Store-কে আপনার ডিভাইসের জন্য সেরা ভ্যারিয়েন্ট বাছাই করতে দিন + পিন করা: %1$s + ভ্যারিয়েন্ট পরিবর্তিত — আবার বাছাই করতে আপডেট ট্যাপ করুন + ভ্যারিয়েন্ট: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index 127bd7d7..c0c2523b 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -618,4 +618,18 @@ Coincidencia en %1$s · %2$d asset Coincidencia en %1$s · %2$d assets + + + Variante preferida + Elige qué variante de APK se instalará en las actualizaciones. La elección se recuerda entre versiones. + No hay assets instalables en la última versión. + No se pudieron cargar las variantes. Verifica tu conexión e inténtalo de nuevo. + No se pudo guardar tu elección. Inténtalo de nuevo. + La variante cambió — elige otra vez + Antes: %1$s + Automática + Deja que GitHub Store elija la mejor variante para tu dispositivo + Fijada en: %1$s + Variante cambiada — toca Actualizar para elegir de nuevo + Variante: %1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 499151e6..5875a0d3 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -619,4 +619,18 @@ Correspondance dans %1$s · %2$d asset Correspondance dans %1$s · %2$d assets + + + Variante préférée + Choisissez la variante d\'APK à installer pour les mises à jour. Le choix est mémorisé entre les versions. + Aucun asset installable dans la dernière version. + Impossible de charger les variantes. Vérifiez votre connexion et réessayez. + Impossible d\'enregistrer votre choix. Réessayez. + La variante a changé — choisissez à nouveau + Avant : %1$s + Automatique + Laisser GitHub Store choisir la meilleure variante pour votre appareil + Épinglée à : %1$s + Variante changée — appuyez sur Mettre à jour pour choisir à nouveau + Variante : %1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index bed065b2..777761d5 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -656,4 +656,18 @@ %1$s में मिला · %2$d एसेट %1$s में मिला · %2$d एसेट + + + पसंदीदा वेरिएंट + चुनें कि अपडेट के लिए कौन सा APK वेरिएंट इंस्टॉल हो। चुनाव रिलीज़ों में याद रखा जाता है। + नवीनतम रिलीज़ में कोई इंस्टॉल योग्य एसेट नहीं है। + वेरिएंट लोड नहीं हो सका। अपना कनेक्शन जाँचें और पुनः प्रयास करें। + आपका चयन सहेजा नहीं जा सका। पुनः प्रयास करें। + वेरिएंट बदल गया — फिर से चुनें + पहले था: %1$s + स्वचालित + GitHub Store को अपने डिवाइस के लिए सर्वोत्तम वेरिएंट चुनने दें + पिन किया गया: %1$s + वेरिएंट बदला — फिर से चुनने के लिए अपडेट टैप करें + वेरिएंट: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 0f656014..2155c4dc 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -657,4 +657,18 @@ Corrispondenza in %1$s · %2$d asset Corrispondenza in %1$s · %2$d asset + + + Variante preferita + Scegli quale variante APK installare per gli aggiornamenti. La scelta viene ricordata tra le release. + Nessun asset installabile nell\'ultima release. + Impossibile caricare le varianti. Controlla la connessione e riprova. + Impossibile salvare la scelta. Riprova. + Variante cambiata — scegli di nuovo + Era: %1$s + Automatica + Lascia che GitHub Store scelga la variante migliore per il tuo dispositivo + Bloccata su: %1$s + Variante cambiata — tocca Aggiorna per scegliere di nuovo + Variante: %1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 9cf42122..11abb9d1 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -618,4 +618,18 @@ %1$s で一致 · %2$d 個のアセット + + + 優先バリアント + アップデートでインストールするAPKバリアントを選択してください。選択はリリース間で記憶されます。 + 最新リリースにインストール可能なアセットがありません。 + バリアントを読み込めませんでした。接続を確認してもう一度お試しください。 + 選択を保存できませんでした。もう一度お試しください。 + バリアントが変更されました — もう一度選択してください + 以前: %1$s + 自動 + GitHub Storeにデバイスに最適なバリアントを選ばせる + 固定中: %1$s + バリアントが変更されました — アップデートをタップして再選択 + バリアント: %1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index 9d48b80b..880dcdb5 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -653,4 +653,18 @@ %1$s에서 일치 · %2$d개 에셋 + + + 선호 변형 + 업데이트에 설치할 APK 변형을 선택하세요. 선택은 릴리스 간에 기억됩니다. + 최신 릴리스에 설치 가능한 에셋이 없습니다. + 변형을 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요. + 선택을 저장할 수 없습니다. 다시 시도하세요. + 변형이 변경되었습니다 — 다시 선택하세요 + 이전: %1$s + 자동 + GitHub Store가 기기에 가장 적합한 변형을 선택하도록 합니다 + 고정됨: %1$s + 변형이 변경되었습니다 — 업데이트를 탭하여 다시 선택하세요 + 변형: %1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 70ab1c95..21e376a0 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -625,4 +625,18 @@ Pasuje w %1$s · %2$d zasobów Pasuje w %1$s · %2$d zasobów + + + Preferowany wariant + Wybierz, który wariant APK ma być instalowany przy aktualizacjach. Wybór jest zapamiętywany między wydaniami. + Brak instalowalnych zasobów w najnowszym wydaniu. + Nie można załadować wariantów. Sprawdź połączenie i spróbuj ponownie. + Nie można zapisać wyboru. Spróbuj ponownie. + Wariant się zmienił — wybierz ponownie + Poprzednio: %1$s + Automatyczny + Pozwól GitHub Store wybrać najlepszy wariant dla Twojego urządzenia + Przypięty do: %1$s + Wariant zmieniony — naciśnij Aktualizuj, aby wybrać ponownie + Wariant: %1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index 450214f2..c35ec1c1 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -625,4 +625,18 @@ Совпадение в %1$s · %2$d ассетов Совпадение в %1$s · %2$d ассетов + + + Предпочтительный вариант + Выберите, какой вариант APK будет устанавливаться при обновлениях. Выбор запоминается между релизами. + В последнем релизе нет устанавливаемых ассетов. + Не удалось загрузить варианты. Проверьте подключение и попробуйте снова. + Не удалось сохранить выбор. Попробуйте снова. + Вариант изменился — выберите снова + Было: %1$s + Автоматически + Пусть GitHub Store сам выберет лучший вариант для вашего устройства + Закреплено: %1$s + Вариант изменился — нажмите Обновить, чтобы выбрать снова + Вариант: %1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 5f8e55b2..1c8ce38f 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -655,4 +655,18 @@ %1$s sürümünde eşleşti · %2$d varlık %1$s sürümünde eşleşti · %2$d varlık + + + Tercih edilen varyant + Güncellemeler için hangi APK varyantının yüklenmesini istediğinizi seçin. Seçim sürümler arası hatırlanır. + En son sürümde yüklenebilir varlık yok. + Varyantlar yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin. + Seçiminiz kaydedilemedi. Tekrar deneyin. + Varyant değişti — tekrar seçin + Önceki: %1$s + Otomatik + Cihazınız için en iyi varyantı GitHub Store seçsin + Sabitlendi: %1$s + Varyant değişti — tekrar seçmek için Güncelle\'ye dokunun + Varyant: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index a74997b8..003f18ac 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -619,4 +619,18 @@ 在 %1$s 中匹配 · %2$d 个资产 + + + 首选变体 + 选择更新时要安装的 APK 变体。此选择会在不同版本之间记住。 + 最新版本中没有可安装的资产。 + 无法加载变体。请检查连接并重试。 + 无法保存您的选择。请重试。 + 变体已更改 — 请重新选择 + 原为:%1$s + 自动 + 让 GitHub Store 为您的设备选择最佳变体 + 已固定为:%1$s + 变体已更改 — 点击更新以重新选择 + 变体:%1$s \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 98afd6b4..24c52a65 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -665,4 +665,18 @@ Matched in %1$s · %2$d asset Matched in %1$s · %2$d assets + + + Preferred variant + Pick which APK variant should be installed for updates. The choice is remembered across releases. + No installable assets in the latest release. + Could not load variants. Check your connection and try again. + Could not save your choice. Try again. + Variant changed — pick again + Was: %1$s + Automatic + Let GitHub Store pick the best variant for your device + Pinned to: %1$s + Variant changed — tap Update to pick again + Variant: %1$s diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt index 0ad2dfc9..51af6edc 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt @@ -26,6 +26,7 @@ import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor +import zed.rainxch.core.domain.util.AssetVariant import zed.rainxch.core.domain.utils.AppLauncher import kotlin.time.Clock @@ -153,10 +154,21 @@ class AppsRepositoryImpl( repoInfo: GithubRepoInfo, assetFilterRegex: String?, fallbackToOlderReleases: Boolean, + pickedAssetName: String?, + pickedAssetSiblingCount: Int, + preferredAssetVariant: String?, ) { val now = Clock.System.now().toEpochMilliseconds() val globalPreRelease = tweaksRepository.getIncludePreReleases().first() val normalizedFilter = assetFilterRegex?.trim()?.takeIf { it.isNotEmpty() } + // Direct tag (from import) wins over derivation from a filename + // (from the link sheet picker). Both fall back to null which + // means "no preference, use the platform auto-picker". + val resolvedVariant = + preferredAssetVariant?.trim()?.takeIf { it.isNotEmpty() } + ?: pickedAssetName?.let { + AssetVariant.deriveFromPickedAsset(it, pickedAssetSiblingCount) + } val installedApp = InstalledApp( @@ -192,6 +204,8 @@ class AppsRepositoryImpl( includePreReleases = globalPreRelease, assetFilterRegex = normalizedFilter, fallbackToOlderReleases = fallbackToOlderReleases, + preferredAssetVariant = resolvedVariant, + preferredVariantStale = false, ) appsRepository.saveInstalledApp(installedApp) @@ -201,7 +215,7 @@ class AppsRepositoryImpl( val apps = appsRepository.getAllInstalledApps().first() val exported = ExportedAppList( - version = 2, + version = 3, exportedAt = Clock.System.now().toEpochMilliseconds(), apps = apps.map { app -> @@ -212,6 +226,7 @@ class AppsRepositoryImpl( repoUrl = app.repoUrl, assetFilterRegex = app.assetFilterRegex, fallbackToOlderReleases = app.fallbackToOlderReleases, + preferredAssetVariant = app.preferredAssetVariant, ) }, ) @@ -261,6 +276,7 @@ class AppsRepositoryImpl( repoInfo = repoInfo, assetFilterRegex = exportedApp.assetFilterRegex, fallbackToOlderReleases = exportedApp.fallbackToOlderReleases, + preferredAssetVariant = exportedApp.preferredAssetVariant, ) imported++ } catch (e: Exception) { diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt index df0a5540..bf5b3c30 100644 --- a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt +++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt @@ -32,6 +32,28 @@ interface AppsRepository { repoInfo: GithubRepoInfo, assetFilterRegex: String? = null, fallbackToOlderReleases: Boolean = false, + /** + * Filename of the asset the user picked in the link sheet (or null + * if no picker was shown — e.g. the repo had no installable assets + * and the link is purely for tracking). When set, the implementation + * derives a stable variant tag from it via `AssetVariant` and + * persists it as `preferredAssetVariant`, so subsequent updates + * stay on the same variant. + */ + pickedAssetName: String? = null, + /** + * How many installable assets were offered to the user when they + * picked. Single-asset releases (count == 1) skip variant memory + * entirely because there's nothing to pin. + */ + pickedAssetSiblingCount: Int = 0, + /** + * Direct variant tag (already extracted) — takes precedence over + * the [pickedAssetName] derivation. Used by the import path + * where we already have the tag from a previous export rather + * than a fresh asset filename to extract from. + */ + preferredAssetVariant: String? = null, ) suspend fun exportApps(): String diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 31594c49..c9b4d80d 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt @@ -78,6 +78,15 @@ sealed interface AppsAction { data object OnAdvancedClearFilter : AppsAction data object OnAdvancedRefreshPreview : AppsAction + // Variant picker dialog (preferred APK variant) + data class OnOpenVariantPicker( + val app: InstalledAppUi, + val resumeUpdateAfterPick: Boolean = false, + ) : AppsAction + data object OnDismissVariantPicker : AppsAction + data class OnVariantSelected(val variant: String?) : AppsAction + data object OnResetVariantToAuto : AppsAction + // Export/Import data object OnExportApps : AppsAction data object OnImportApps : AppsAction diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index 9981fcab..b92de12a 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -88,6 +88,7 @@ import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.apps.presentation.components.AdvancedAppSettingsBottomSheet import zed.rainxch.apps.presentation.components.InstalledAppIcon import zed.rainxch.apps.presentation.components.LinkAppBottomSheet +import zed.rainxch.apps.presentation.components.VariantPickerDialog import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.AppSortRule import zed.rainxch.apps.presentation.model.UpdateAllProgress @@ -102,6 +103,8 @@ import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.add_by_link import zed.rainxch.githubstore.core.presentation.res.advanced_settings_open +import zed.rainxch.githubstore.core.presentation.res.variant_label_inline +import zed.rainxch.githubstore.core.presentation.res.variant_stale_hint import zed.rainxch.githubstore.core.presentation.res.cancel import zed.rainxch.githubstore.core.presentation.res.check_for_updates import zed.rainxch.githubstore.core.presentation.res.checking @@ -337,6 +340,14 @@ fun AppsScreen( ) } + // Variant picker dialog (shown for stale variants or explicit picks) + if (state.variantPickerApp != null) { + VariantPickerDialog( + state = state, + onAction = onAction, + ) + } + // Uninstall confirmation dialog state.appPendingUninstall?.let { app -> AlertDialog( @@ -688,6 +699,18 @@ fun AppItemCard( ) } + app.preferredVariantStale -> { + // Tap-to-fix label: route through the same OnUpdateApp + // intercept that would have opened the picker anyway, + // but also surface a tappable hint here for users + // who notice the warning before tapping Update. + Text( + text = stringResource(Res.string.variant_stale_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + app.isUpdateAvailable -> { Text( text = @@ -700,6 +723,20 @@ fun AppItemCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) + // Show the pinned variant tag inline so users can + // see at a glance which APK they'll get when they + // tap Update. + if (!app.preferredAssetVariant.isNullOrBlank()) { + Text( + text = + stringResource( + Res.string.variant_label_inline, + app.preferredAssetVariant, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } else -> { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt index b7425eeb..91705521 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt @@ -57,6 +57,20 @@ data class AppsState( val advancedPreviewTag: String? = null, val advancedPreviewMessage: String? = null, val advancedSavingFilter: Boolean = false, + // Variant picker dialog (shown when preferredVariantStale, when the + // user explicitly opens it from advanced settings, or when they tap + // Update on a stale-variant app) + val variantPickerApp: InstalledAppUi? = null, + val variantPickerLoading: Boolean = false, + val variantPickerOptions: ImmutableList = persistentListOf(), + val variantPickerCurrentVariant: String? = null, + val variantPickerError: String? = null, + /** + * Set when the picker is being shown specifically because the user + * tapped Update on a stale-variant app — after they pick we should + * automatically resume the update flow. + */ + val variantPickerResumeUpdateAfterPick: Boolean = false, // Export/Import val isExporting: Boolean = false, val isImporting: Boolean = false, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index 6c0c7c71..f437ea9f 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -36,6 +36,7 @@ import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.util.AssetFilter +import zed.rainxch.core.domain.util.AssetVariant import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.core.presentation.res.* import java.io.File @@ -213,7 +214,15 @@ class AppsViewModel( } is AppsAction.OnUpdateApp -> { - updateSingleApp(action.app) + // If the app's pinned variant has gone missing in the + // latest release we don't know what to download — open + // the picker first and resume the update after the user + // chooses. Saves them from a wrong-variant install. + if (action.app.preferredVariantStale) { + openVariantPicker(action.app, resumeUpdateAfterPick = true) + } else { + updateSingleApp(action.app) + } } is AppsAction.OnCancelUpdate -> { @@ -379,6 +388,31 @@ class AppsViewModel( refreshAdvancedPreview() } + is AppsAction.OnOpenVariantPicker -> { + openVariantPicker(action.app, action.resumeUpdateAfterPick) + } + + AppsAction.OnDismissVariantPicker -> { + _state.update { + it.copy( + variantPickerApp = null, + variantPickerOptions = persistentListOf(), + variantPickerCurrentVariant = null, + variantPickerError = null, + variantPickerLoading = false, + variantPickerResumeUpdateAfterPick = false, + ) + } + } + + is AppsAction.OnVariantSelected -> { + saveVariantSelection(action.variant) + } + + AppsAction.OnResetVariantToAuto -> { + saveVariantSelection(null) + } + AppsAction.OnExportApps -> { exportApps() } @@ -565,6 +599,113 @@ class AppsViewModel( } } + /** + * Opens the variant picker for [app]. Fetches the current latest + * matching release (honouring the per-app filter / fallback) so the + * dialog can show real, current asset names — not the cached ones + * which might be stale or wrong. When [resumeUpdateAfterPick] is + * true, dispatch the update flow as soon as the user picks. + */ + private fun openVariantPicker( + app: InstalledAppUi, + resumeUpdateAfterPick: Boolean, + ) { + _state.update { + it.copy( + variantPickerApp = app, + variantPickerLoading = true, + variantPickerOptions = persistentListOf(), + variantPickerCurrentVariant = app.preferredAssetVariant, + variantPickerError = null, + variantPickerResumeUpdateAfterPick = resumeUpdateAfterPick, + ) + } + viewModelScope.launch { + try { + val preview = + installedAppsRepository.previewMatchingAssets( + owner = app.repoOwner, + repo = app.repoName, + regex = app.assetFilterRegex, + includePreReleases = app.includePreReleases, + fallbackToOlderReleases = app.fallbackToOlderReleases, + ) + _state.update { + it.copy( + variantPickerLoading = false, + variantPickerOptions = + preview.matchedAssets + .map { asset -> asset.toUi() } + .toImmutableList(), + variantPickerError = + if (preview.matchedAssets.isEmpty()) "no_assets" else null, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Failed to load variant picker for ${app.packageName}: ${e.message}") + _state.update { + it.copy( + variantPickerLoading = false, + variantPickerError = "load_failed", + ) + } + } + } + } + + /** + * Persists the user's variant pick (or null to reset to auto), + * dismisses the dialog, and — if the picker was opened from a "tap + * Update on stale variant" flow — kicks the update off automatically + * with the freshly-resolved cached fields. + */ + private fun saveVariantSelection(variant: String?) { + val app = _state.value.variantPickerApp ?: return + val resume = _state.value.variantPickerResumeUpdateAfterPick + + viewModelScope.launch { + try { + installedAppsRepository.setPreferredVariant( + packageName = app.packageName, + variant = variant, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Failed to save preferred variant for ${app.packageName}: ${e.message}") + _state.update { it.copy(variantPickerError = "save_failed") } + return@launch + } + + // Dismiss the dialog regardless of whether we resume. + _state.update { + it.copy( + variantPickerApp = null, + variantPickerOptions = persistentListOf(), + variantPickerCurrentVariant = null, + variantPickerError = null, + variantPickerLoading = false, + variantPickerResumeUpdateAfterPick = false, + ) + } + + if (resume) { + // Pick up the freshly-checked InstalledAppUi (the + // setPreferredVariant flow already re-ran checkForUpdates) + // and kick off the update with the new variant. + val refreshed = + _state.value.apps + .firstOrNull { it.installedApp.packageName == app.packageName } + ?.installedApp + if (refreshed != null) { + updateSingleApp(refreshed) + } + } + } + } + private fun saveAdvancedSettings() { val app = _state.value.advancedSettingsApp ?: return val draftFilter = _state.value.advancedFilterDraft.trim() @@ -698,8 +839,21 @@ class AppsViewModel( throw IllegalStateException("No installable assets found for this platform") } + // Honour the user's pinned variant first; fall back to + // the platform installer's auto-pick if the variant + // isn't present in this release. The auto-pick + // intentionally never throws here — checkForUpdates + // already flipped `preferredVariantStale=true` and the + // earlier intercept (see updateSingleApp entrypoint) + // would have routed us to the picker dialog instead. + val variantMatch = + AssetVariant.resolvePreferredAsset( + assets = installableAssets, + preferredVariant = app.preferredAssetVariant, + ) val primaryAsset = - installer.choosePrimaryAsset(installableAssets) + variantMatch + ?: installer.choosePrimaryAsset(installableAssets) ?: throw IllegalStateException("Could not determine primary asset") logger.debug( @@ -1335,6 +1489,8 @@ class AppsViewModel( repoInfo = repoInfo.toDomain(), assetFilterRegex = _state.value.linkAssetFilter.takeIf { it.isNotBlank() }, fallbackToOlderReleases = _state.value.linkFallbackToOlder, + pickedAssetName = asset.name, + pickedAssetSiblingCount = _state.value.linkInstallableAssets.size, ) _state.update { it.copy( @@ -1392,6 +1548,8 @@ class AppsViewModel( repoInfo = repoInfo.toDomain(), assetFilterRegex = _state.value.linkAssetFilter.takeIf { it.isNotBlank() }, fallbackToOlderReleases = _state.value.linkFallbackToOlder, + pickedAssetName = asset.name, + pickedAssetSiblingCount = _state.value.linkInstallableAssets.size, ) _state.update { it.copy( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt index a7140464..df1a4533 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt @@ -14,11 +14,15 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.clickable import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton @@ -175,7 +179,23 @@ fun AdvancedAppSettingsBottomSheet( ) } - Spacer(Modifier.height(20.dp)) + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + Spacer(Modifier.height(16.dp)) + + // === Preferred variant row === + // Tappable row that opens the variant picker dialog. Shows + // the currently-pinned variant tag (or "Auto" when none), + // and warns the user when the pin has gone stale. + VariantRow( + pinnedVariant = app.preferredAssetVariant, + isStale = app.preferredVariantStale, + onClick = { onAction(AppsAction.OnOpenVariantPicker(app)) }, + ) + + Spacer(Modifier.height(16.dp)) HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), ) @@ -358,6 +378,65 @@ private fun PreviewSection( } } +@Composable +private fun VariantRow( + pinnedVariant: String?, + isStale: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isStale) Icons.Default.Warning else Icons.Default.Tune, + contentDescription = null, + tint = + if (isStale) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.variant_picker_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = + when { + isStale -> + stringResource(Res.string.variant_picker_stale_title) + pinnedVariant.isNullOrBlank() -> + stringResource(Res.string.variant_picker_auto_subtitle) + else -> + stringResource(Res.string.variant_picker_pinned, pinnedVariant) + }, + style = MaterialTheme.typography.bodySmall, + color = + if (isStale) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } +} + private fun formatPreviewSize(bytes: Long): String = when { bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt new file mode 100644 index 00000000..3145cfa1 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt @@ -0,0 +1,308 @@ +package zed.rainxch.apps.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.apps.presentation.AppsAction +import zed.rainxch.apps.presentation.AppsState +import zed.rainxch.core.domain.util.AssetVariant +import zed.rainxch.githubstore.core.presentation.res.* + +/** + * Dialog for picking the preferred APK variant when an app's release + * has multiple installable assets. Opened from: + * - The advanced settings sheet (explicit user action) + * - Tapping Update on an app whose `preferredVariantStale` is true + * (the picker takes over so the user resolves the ambiguity before + * the wrong APK gets downloaded) + * + * Each option corresponds to a stable variant tag derived from the + * filename. There's also a "Reset to auto" entry that clears the + * preference and lets the platform installer's auto-picker do its job. + */ +@Composable +fun VariantPickerDialog( + state: AppsState, + onAction: (AppsAction) -> Unit, +) { + val app = state.variantPickerApp ?: return + + AlertDialog( + onDismissRequest = { onAction(AppsAction.OnDismissVariantPicker) }, + title = { + Column { + Text( + text = stringResource(Res.string.variant_picker_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = app.appName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + if (app.preferredVariantStale) { + StaleVariantBanner(currentVariant = state.variantPickerCurrentVariant) + Spacer(Modifier.height(12.dp)) + } + + Text( + text = stringResource(Res.string.variant_picker_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + when { + state.variantPickerLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(strokeWidth = 2.dp) + } + } + + state.variantPickerError == "no_assets" -> { + Text( + text = stringResource(Res.string.variant_picker_no_assets), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + state.variantPickerError == "load_failed" -> { + Text( + text = stringResource(Res.string.variant_picker_load_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + state.variantPickerError == "save_failed" -> { + Text( + text = stringResource(Res.string.variant_picker_save_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + else -> { + VariantOptionList( + state = state, + onPick = { variant -> + onAction(AppsAction.OnVariantSelected(variant)) + }, + onResetAuto = { onAction(AppsAction.OnResetVariantToAuto) }, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onAction(AppsAction.OnDismissVariantPicker) }) { + Text(stringResource(Res.string.cancel)) + } + }, + ) +} + +@Composable +private fun StaleVariantBanner( + currentVariant: String?, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.errorContainer, + RoundedCornerShape(12.dp), + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.variant_picker_stale_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + if (currentVariant != null) { + Text( + text = stringResource(Res.string.variant_picker_stale_was, currentVariant), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} + +@Composable +private fun VariantOptionList( + state: AppsState, + onPick: (variant: String?) -> Unit, + onResetAuto: () -> Unit, +) { + val current = state.variantPickerCurrentVariant + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 0.dp, max = 280.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + // Reset-to-auto entry — placed at the top so it's always discoverable. + item { + VariantRow( + isSelected = current == null, + title = stringResource(Res.string.variant_picker_auto_title), + subtitle = stringResource(Res.string.variant_picker_auto_subtitle), + leadingIcon = Icons.Default.AutoAwesome, + onClick = onResetAuto, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + } + + items(state.variantPickerOptions, key = { it.id }) { asset -> + val variant = AssetVariant.extract(asset.name) + val isCurrent = variant != null && variant.equals(current, ignoreCase = true) + VariantRow( + isSelected = isCurrent, + title = variant?.takeIf { it.isNotBlank() } ?: asset.name, + subtitle = asset.name + " · " + formatBytes(asset.size), + onClick = { variant?.let(onPick) }, + ) + } + } +} + +@Composable +private fun VariantRow( + isSelected: Boolean, + title: String, + subtitle: String, + onClick: () -> Unit, + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + when { + isSelected -> Icons.Default.RadioButtonChecked + else -> Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (isSelected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } +} + +private fun formatBytes(bytes: Long): String = + when { + bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) + else -> "$bytes B" + } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/InstalledAppMapper.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/InstalledAppMapper.kt index 138e5163..c292b849 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/InstalledAppMapper.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/mappers/InstalledAppMapper.kt @@ -40,6 +40,8 @@ fun InstalledApp.toUi(): InstalledAppUi = includePreReleases = includePreReleases, assetFilterRegex = assetFilterRegex, fallbackToOlderReleases = fallbackToOlderReleases, + preferredAssetVariant = preferredAssetVariant, + preferredVariantStale = preferredVariantStale, ) fun InstalledAppUi.toDomain(): InstalledApp = @@ -79,4 +81,6 @@ fun InstalledAppUi.toDomain(): InstalledApp = includePreReleases = includePreReleases, assetFilterRegex = assetFilterRegex, fallbackToOlderReleases = fallbackToOlderReleases, + preferredAssetVariant = preferredAssetVariant, + preferredVariantStale = preferredVariantStale, ) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/InstalledAppUi.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/InstalledAppUi.kt index c988ee13..f56a9c83 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/InstalledAppUi.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/model/InstalledAppUi.kt @@ -38,4 +38,6 @@ data class InstalledAppUi( val includePreReleases: Boolean = false, val assetFilterRegex: String? = null, val fallbackToOlderReleases: Boolean = false, + val preferredAssetVariant: String? = null, + val preferredVariantStale: Boolean = false, ) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 271eb345..b45408ec 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -38,6 +38,7 @@ import zed.rainxch.core.domain.system.InstallOutcome import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase +import zed.rainxch.core.domain.util.AssetVariant import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.details.domain.model.ApkValidationResult @@ -370,6 +371,41 @@ class DetailsViewModel( is DetailsAction.SelectDownloadAsset -> { _state.update { state -> state.copy(primaryAsset = action.release) } + // If this app is already tracked and there are multiple + // installable assets to choose from, the user just made + // an explicit variant choice — persist it so future + // updates from the apps list (and the background worker) + // stay on the same variant. Single-asset releases skip + // this; AssetVariant.deriveFromPickedAsset returns null. + val installedApp = _state.value.installedApp + val installable = _state.value.installableAssets + if (installedApp != null) { + val variant = + AssetVariant.deriveFromPickedAsset( + pickedAssetName = action.release.name, + siblingAssetCount = installable.size, + ) + val current = installedApp.preferredAssetVariant + // Avoid hammering the DB / re-check when the user + // tapped the asset that was already pinned. + if (variant != null && !variant.equals(current, ignoreCase = true)) { + viewModelScope.launch { + try { + installedAppsRepository.setPreferredVariant( + packageName = installedApp.packageName, + variant = variant, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error( + "Failed to persist preferred variant for " + + "${installedApp.packageName}: ${e.message}", + ) + } + } + } + } } DetailsAction.ToggleReleaseAssetsPicker -> { @@ -390,7 +426,17 @@ class DetailsViewModel( ?.filter { asset -> installer.isAssetInstallable(asset.name) }.orEmpty() - val primary = installer.choosePrimaryAsset(installable) + + // If the user has a tracked variant for this app and the current + // release contains an asset with the same variant tag, default + // the picker to it. Falling back to the platform installer's + // arch-aware auto-pick keeps the existing behaviour for + // non-tracked repos and for tracked apps whose variant doesn't + // appear in this particular release. + val preferredVariant = _state.value.installedApp?.preferredAssetVariant + val variantMatch = + AssetVariant.resolvePreferredAsset(installable, preferredVariant) + val primary = variantMatch ?: installer.choosePrimaryAsset(installable) return installable to primary } From 656cfb76f473cac78a7c7427b94e5bc9cacefd1e Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 11 Apr 2026 13:40:09 +0500 Subject: [PATCH 2/3] Improve version normalization in `InstalledAppsRepositoryImpl` - Enhance `normalizeVersion` to better handle non-standard version tags and prefixes (e.g., `release-1.2.0`, `App-v1.2.0`). - Update logic to strip build metadata (text after `+`) as per SemVer specifications. - Add regex-based extraction to isolate dotted-digit substrings when standard prefix stripping fails. - Fixes an issue where lexicographic comparisons incorrectly flagged updates when version strings contained leading non-numeric characters. --- .../repository/InstalledAppsRepositoryImpl.kt | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 9351641c..7d4ae57b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -486,7 +486,49 @@ class InstalledAppsRepositoryImpl( ) } - private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim() + /** + * Reduces a tag or installed-version string to a form that + * [parseSemanticVersion] can actually digest. + * + * Why this matters: when a user sideloads an update from outside + * GitHub Store, [SyncInstalledAppsUseCase] picks up the new + * `versionName` from the Android package manager and writes it back + * to `installedVersion`. But the immediately-following + * [checkForUpdates] then compares that fresh value against the + * GitHub release `tagName`. If the maintainer publishes tags like + * `release-1.2.0` or `App-1.2.0` (any prefix that isn't just `v`), + * the OLD normalize-by-stripping-v left them alone, the equality + * check failed, and [isVersionNewer] fell through to a lexicographic + * comparison where the leading letter (`'r'` = 114) is "greater + * than" the digit (`'1'` = 49), incorrectly re-flagging the update. + * + * The new normalization tries, in order: + * 1. Strip leading `v` / `V` + * 2. Drop `+build` metadata (semver says it's ignored for ordering) + * 3. If the result is still not parseable, extract the first + * dotted-digit substring (optionally followed by a `-pre` + * identifier) and use that. + * + * Examples: + * `v1.2.3` → `1.2.3` + * `1.2.3+sha.abcd` → `1.2.3` + * `1.2.3-rc1` → `1.2.3-rc1` (preserved — affects ordering) + * `release-1.2.0` → `1.2.0` + * `App-v1.2.0-stable` → `1.2.0-stable` + * `build-2025.04.10` → `2025.04.10` + * `not-a-version` → `not-a-version` (unchanged — let caller fall back) + */ + private fun normalizeVersion(version: String): String { + val cleaned = version.trim().removePrefix("v").removePrefix("V").trim() + val withoutBuildMetadata = cleaned.substringBefore('+') + if (parseSemanticVersion(withoutBuildMetadata) != null) { + return withoutBuildMetadata + } + val match = + Regex("""\d+(?:\.\d+)*(?:-[\w.]+)?""") + .find(withoutBuildMetadata) + return match?.value ?: withoutBuildMetadata + } /** * Compare two version strings and return true if [candidate] is newer than [current]. From 07dfe6206476a5e915e327edbd42b12ef21464f9 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 11 Apr 2026 14:05:52 +0500 Subject: [PATCH 3/3] feat: enhance asset variant picking and pinning - **Localization**: Added `variant_picker_no_pinnable` string across multiple languages (English, Arabic, Turkish, Chinese, Bengali, Japanese, Korean, Polish, Italian, French, Russian, Hindi, Spanish) to inform users when a release lacks version-tagged variants for pinning. - **Domain Logic**: Refined the `AssetVariant` extraction regex to require at least two dotted digit groups and a clean token boundary, preventing false matches on architecture tokens (e.g., `-v8a`). - **Data Layer**: - Updated `InstalledAppEntity` with `@ColumnInfo(defaultValue = "0")` for `fallbackToOlderReleases` and `preferredVariantStale` to ensure Room schema consistency. - Incremented database identity hash in schema version 11. - **Presentation Logic**: - **AppsViewModel**: Filtered variant picker options to only include "pinnable" assets (those with extractable variant tags). - **AppsViewModel**: Changed the post-selection update flow to perform a direct DAO read from the repository to prevent race conditions with asynchronous state updates. - **DetailsViewModel**: Updated logic to allow re-saving the same variant if the `preferredVariantStale` flag is set, ensuring the stale warning can be cleared. - **VariantPickerDialog**: UI now displays the specific "no pinnable variants" error message and skips rendering invalid assets. --- .../11.json | 12 ++++--- .../local/db/entities/InstalledAppEntity.kt | 11 ++++++ .../rainxch/core/domain/util/AssetVariant.kt | 34 +++++++++++++++--- .../composeResources/values-ar/strings-ar.xml | 1 + .../composeResources/values-bn/strings-bn.xml | 1 + .../composeResources/values-es/strings-es.xml | 1 + .../composeResources/values-fr/strings-fr.xml | 1 + .../composeResources/values-hi/strings-hi.xml | 1 + .../composeResources/values-it/strings-it.xml | 1 + .../composeResources/values-ja/strings-ja.xml | 1 + .../composeResources/values-ko/strings-ko.xml | 1 + .../composeResources/values-pl/strings-pl.xml | 1 + .../composeResources/values-ru/strings-ru.xml | 1 + .../composeResources/values-tr/strings-tr.xml | 1 + .../values-zh-rCN/strings-zh-rCN.xml | 1 + .../composeResources/values/strings.xml | 1 + .../apps/presentation/AppsViewModel.kt | 35 ++++++++++++++----- .../components/VariantPickerDialog.kt | 20 +++++++++-- .../details/presentation/DetailsViewModel.kt | 13 +++++-- 19 files changed, 114 insertions(+), 24 deletions(-) diff --git a/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json index d6afadaf..b7a528ac 100644 --- a/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json +++ b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "4ef44cac070a165197c60c485f778601", + "identityHash": "8954d1c4c5648189aa99dc390d5b39e3", "entities": [ { "tableName": "installed_apps", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `signingFingerprint` TEXT, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, `latestReleasePublishedAt` TEXT, `includePreReleases` INTEGER NOT NULL, `assetFilterRegex` TEXT, `fallbackToOlderReleases` INTEGER NOT NULL, `preferredAssetVariant` TEXT, `preferredVariantStale` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `signingFingerprint` TEXT, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, `latestReleasePublishedAt` TEXT, `includePreReleases` INTEGER NOT NULL, `assetFilterRegex` TEXT, `fallbackToOlderReleases` INTEGER NOT NULL DEFAULT 0, `preferredAssetVariant` TEXT, `preferredVariantStale` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", @@ -201,7 +201,8 @@ "fieldPath": "fallbackToOlderReleases", "columnName": "fallbackToOlderReleases", "affinity": "INTEGER", - "notNull": true + "notNull": true, + "defaultValue": "0" }, { "fieldPath": "preferredAssetVariant", @@ -212,7 +213,8 @@ "fieldPath": "preferredVariantStale", "columnName": "preferredVariantStale", "affinity": "INTEGER", - "notNull": true + "notNull": true, + "defaultValue": "0" } ], "primaryKey": { @@ -613,7 +615,7 @@ ], "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, '4ef44cac070a165197c60c485f778601')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8954d1c4c5648189aa99dc390d5b39e3')" ] } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt index 61bc121a..917a404e 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt @@ -1,5 +1,6 @@ package zed.rainxch.core.data.local.db.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import zed.rainxch.core.domain.model.InstallSource @@ -50,7 +51,12 @@ data class InstalledAppEntity( * When true, the update checker walks backward through past releases until * it finds one whose assets match [assetFilterRegex]. Required for * monorepos where the latest release is for a *different* app. + * + * `@ColumnInfo(defaultValue = "0")` matches `MIGRATION_9_10`'s + * `DEFAULT 0` clause so Room's schema validator doesn't flag the + * column on freshly-built databases. */ + @ColumnInfo(defaultValue = "0") val fallbackToOlderReleases: Boolean = false, /** * Stable identifier for the asset variant the user wants to track — @@ -70,6 +76,11 @@ data class InstalledAppEntity( * assets — typically because the maintainer renamed or restructured * the artefacts. The UI surfaces this with a "variant changed — * pick again" prompt and clears it once the user picks a new variant. + * + * `@ColumnInfo(defaultValue = "0")` matches `MIGRATION_10_11`'s + * `DEFAULT 0` clause so Room's schema validator doesn't flag a + * mismatch between the migrated table and the freshly-created one. */ + @ColumnInfo(defaultValue = "0") val preferredVariantStale: Boolean = false, ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt index dd9fec0f..e51c3316 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt @@ -24,13 +24,37 @@ import zed.rainxch.core.domain.model.GithubAsset */ object AssetVariant { /** - * Matches the FIRST version-looking segment in a filename: an - * optional `v`/`V`, one or more digits, and any number of dotted - * digit groups after that. The leading character is required to be a - * separator so we don't false-match on names like `app2-installer`. + * Matches the FIRST version-looking segment in a filename. We require: + * + * - A leading separator (`-`, `_`, or space) so we don't false-match + * on names like `app2-installer` where `2` is part of the app name + * - An optional `v`/`V` prefix (e.g. `-v1.2.3`) + * - At least **two** dotted digit groups (`\d+(?:\.\d+)+`) so we don't + * swallow architecture tokens like `_64`, `-v8`, or `-v7a` that have + * no dots and are common in APK filenames + * - A trailing token boundary (a separator or end-of-string) so we + * don't accept partial matches like `1.2.3pre` (which would otherwise + * leak `pre` into the variant tail) + * + * Examples that **do** match (and what gets captured): + * + * `app-1.2.3` → `-1.2.3` + * `myapp-v2.0.1-arm64` → `-v2.0.1` + * `App_3.4.5_universal` → `_3.4.5` + * + * Examples that **don't** match (and why): + * + * `arm64-v8a-app-1.2.3` → `-v8` is rejected (no dot); `-1.2.3` matches + * instead, leaving an empty variant tail — + * preferable to extracting `a` as the variant + * `app_64bit_v1.2.3` → `_64` is rejected (no dot); `_v1.2.3` matches + * `app-1` → No match — single-digit versions are too + * ambiguous; the auto-picker handles them + * `app-1.2.3pre` → No match — the trailing `pre` (no separator) + * isn't a clean token boundary */ private val VERSION_SEGMENT = - Regex("[-_ ]v?\\d+(?:\\.\\d+)*", RegexOption.IGNORE_CASE) + Regex("[-_ ]v?\\d+(?:\\.\\d+)+(?=[-_. ]|$)", RegexOption.IGNORE_CASE) private val LEADING_SEPARATORS = charArrayOf('-', '_', ' ', '.') diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 4c35bea2..c7e45e40 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -663,6 +663,7 @@ النوع المفضل اختر نوع APK الذي يجب تثبيته للتحديثات. يتم تذكر الاختيار عبر الإصدارات. لا توجد أصول قابلة للتثبيت في أحدث إصدار. + لا تحتوي هذه النسخة على أنواع موسومة بالإصدار يمكن تثبيتها. سيستمر المحدد التلقائي في اختيار أفضل أصل لجهازك. تعذر تحميل الأنواع. تحقق من اتصالك وحاول مرة أخرى. تعذر حفظ اختيارك. حاول مرة أخرى. تغير النوع — اختر مرة أخرى diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index fd475d08..25b61b82 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -662,6 +662,7 @@ পছন্দের ভ্যারিয়েন্ট আপডেটের জন্য কোন APK ভ্যারিয়েন্ট ইনস্টল হবে তা বেছে নিন। পছন্দ রিলিজ জুড়ে মনে রাখা হয়। সর্বশেষ রিলিজে কোনো ইনস্টলযোগ্য অ্যাসেট নেই। + এই রিলিজে পিন করার মতো সংস্করণ-ট্যাগযুক্ত ভ্যারিয়েন্ট নেই। স্বয়ংক্রিয় নির্বাচক আপনার ডিভাইসের জন্য সেরা অ্যাসেট বেছে নেওয়া চালিয়ে যাবে। ভ্যারিয়েন্ট লোড করা যায়নি। আপনার সংযোগ যাচাই করে আবার চেষ্টা করুন। আপনার পছন্দ সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন। ভ্যারিয়েন্ট পরিবর্তিত — আবার বেছে নিন diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index c0c2523b..72b9fdba 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -623,6 +623,7 @@ Variante preferida Elige qué variante de APK se instalará en las actualizaciones. La elección se recuerda entre versiones. No hay assets instalables en la última versión. + Esta versión no tiene variantes etiquetadas con versión que se puedan fijar. El selector automático seguirá eligiendo el mejor asset para tu dispositivo. No se pudieron cargar las variantes. Verifica tu conexión e inténtalo de nuevo. No se pudo guardar tu elección. Inténtalo de nuevo. La variante cambió — elige otra vez diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 5875a0d3..cd4b2ef5 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -624,6 +624,7 @@ Variante préférée Choisissez la variante d\'APK à installer pour les mises à jour. Le choix est mémorisé entre les versions. Aucun asset installable dans la dernière version. + Cette version n\'a pas de variantes étiquetées par version à épingler. Le sélecteur automatique continuera à choisir le meilleur asset pour votre appareil. Impossible de charger les variantes. Vérifiez votre connexion et réessayez. Impossible d\'enregistrer votre choix. Réessayez. La variante a changé — choisissez à nouveau diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 777761d5..d7eb18d5 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -661,6 +661,7 @@ पसंदीदा वेरिएंट चुनें कि अपडेट के लिए कौन सा APK वेरिएंट इंस्टॉल हो। चुनाव रिलीज़ों में याद रखा जाता है। नवीनतम रिलीज़ में कोई इंस्टॉल योग्य एसेट नहीं है। + इस रिलीज़ में पिन करने योग्य संस्करण-टैग वाले वेरिएंट नहीं हैं। स्वचालित चयनकर्ता आपके डिवाइस के लिए सबसे अच्छा एसेट चुनना जारी रखेगा। वेरिएंट लोड नहीं हो सका। अपना कनेक्शन जाँचें और पुनः प्रयास करें। आपका चयन सहेजा नहीं जा सका। पुनः प्रयास करें। वेरिएंट बदल गया — फिर से चुनें diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 2155c4dc..77ea87eb 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -662,6 +662,7 @@ Variante preferita Scegli quale variante APK installare per gli aggiornamenti. La scelta viene ricordata tra le release. Nessun asset installabile nell\'ultima release. + Questa release non ha varianti con versione da bloccare. Il selettore automatico continuerà a scegliere l\'asset migliore per il tuo dispositivo. Impossibile caricare le varianti. Controlla la connessione e riprova. Impossibile salvare la scelta. Riprova. Variante cambiata — scegli di nuovo diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 11abb9d1..a3464c55 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -623,6 +623,7 @@ 優先バリアント アップデートでインストールするAPKバリアントを選択してください。選択はリリース間で記憶されます。 最新リリースにインストール可能なアセットがありません。 + このリリースには固定できるバージョン付きバリアントがありません。自動セレクターは引き続きデバイスに最適なアセットを選択します。 バリアントを読み込めませんでした。接続を確認してもう一度お試しください。 選択を保存できませんでした。もう一度お試しください。 バリアントが変更されました — もう一度選択してください diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index 880dcdb5..9f768c7e 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -658,6 +658,7 @@ 선호 변형 업데이트에 설치할 APK 변형을 선택하세요. 선택은 릴리스 간에 기억됩니다. 최신 릴리스에 설치 가능한 에셋이 없습니다. + 이 릴리스에는 고정할 수 있는 버전 태그가 있는 변형이 없습니다. 자동 선택기는 계속해서 기기에 가장 적합한 에셋을 선택합니다. 변형을 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요. 선택을 저장할 수 없습니다. 다시 시도하세요. 변형이 변경되었습니다 — 다시 선택하세요 diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 21e376a0..ff9ff81a 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -630,6 +630,7 @@ Preferowany wariant Wybierz, który wariant APK ma być instalowany przy aktualizacjach. Wybór jest zapamiętywany między wydaniami. Brak instalowalnych zasobów w najnowszym wydaniu. + To wydanie nie ma wariantów oznaczonych wersją do przypięcia. Automatyczny selektor będzie nadal wybierał najlepszy zasób dla Twojego urządzenia. Nie można załadować wariantów. Sprawdź połączenie i spróbuj ponownie. Nie można zapisać wyboru. Spróbuj ponownie. Wariant się zmienił — wybierz ponownie diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index c35ec1c1..e52a9da0 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -630,6 +630,7 @@ Предпочтительный вариант Выберите, какой вариант APK будет устанавливаться при обновлениях. Выбор запоминается между релизами. В последнем релизе нет устанавливаемых ассетов. + В этом релизе нет вариантов с тегом версии, которые можно закрепить. Автоматический селектор продолжит выбирать лучший ассет для вашего устройства. Не удалось загрузить варианты. Проверьте подключение и попробуйте снова. Не удалось сохранить выбор. Попробуйте снова. Вариант изменился — выберите снова diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 1c8ce38f..9a688480 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -660,6 +660,7 @@ Tercih edilen varyant Güncellemeler için hangi APK varyantının yüklenmesini istediğinizi seçin. Seçim sürümler arası hatırlanır. En son sürümde yüklenebilir varlık yok. + Bu sürümde sabitlenebilecek sürüm etiketli varyant yok. Otomatik seçici cihazınız için en iyi varlığı seçmeye devam edecek. Varyantlar yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin. Seçiminiz kaydedilemedi. Tekrar deneyin. Varyant değişti — tekrar seçin diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 003f18ac..71c6e268 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -624,6 +624,7 @@ 首选变体 选择更新时要安装的 APK 变体。此选择会在不同版本之间记住。 最新版本中没有可安装的资产。 + 此版本没有可固定的版本标记变体。自动选择器将继续为您的设备选择最佳资产。 无法加载变体。请检查连接并重试。 无法保存您的选择。请重试。 变体已更改 — 请重新选择 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 24c52a65..101ea12f 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -670,6 +670,7 @@ Preferred variant Pick which APK variant should be installed for updates. The choice is remembered across releases. No installable assets in the latest release. + This release has no version-tagged variants to pin. The automatic picker will continue choosing the best asset for your device. Could not load variants. Check your connection and try again. Could not save your choice. Try again. Variant changed — pick again diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index f437ea9f..23131fd0 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -630,15 +630,28 @@ class AppsViewModel( includePreReleases = app.includePreReleases, fallbackToOlderReleases = app.fallbackToOlderReleases, ) + // Only assets whose filename has an extractable, non-empty + // variant tag are pinnable: an empty extract or null means + // we'd have nothing to remember release-over-release. The + // dialog filters its own list so users can't tap a row + // that would silently no-op. + val pinnableAssets = + preview.matchedAssets.filter { asset -> + AssetVariant.extract(asset.name)?.isNotEmpty() == true + } _state.update { it.copy( variantPickerLoading = false, variantPickerOptions = - preview.matchedAssets + pinnableAssets .map { asset -> asset.toUi() } .toImmutableList(), variantPickerError = - if (preview.matchedAssets.isEmpty()) "no_assets" else null, + when { + preview.matchedAssets.isEmpty() -> "no_assets" + pinnableAssets.isEmpty() -> "no_pinnable_variants" + else -> null + }, ) } } catch (e: CancellationException) { @@ -692,13 +705,19 @@ class AppsViewModel( } if (resume) { - // Pick up the freshly-checked InstalledAppUi (the - // setPreferredVariant flow already re-ran checkForUpdates) - // and kick off the update with the new variant. + // Read the canonical InstalledApp directly from the + // repository rather than the in-memory state. The Flow + // that drives `_state.value.apps` propagates DAO writes + // asynchronously, so reading state right after + // setPreferredVariant — which itself runs an internal + // checkForUpdates write — can race and hand us the OLD + // pre-pick row, leading to an update with the wrong + // asset URL. A direct DAO read is synchronous and never + // races against pending Flow emissions. val refreshed = - _state.value.apps - .firstOrNull { it.installedApp.packageName == app.packageName } - ?.installedApp + runCatching { installedAppsRepository.getAppByPackage(app.packageName) } + .getOrNull() + ?.toUi() if (refreshed != null) { updateSingleApp(refreshed) } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt index 3145cfa1..333b1db7 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt @@ -115,6 +115,15 @@ fun VariantPickerDialog( ) } + state.variantPickerError == "no_pinnable_variants" -> { + Text( + text = stringResource(Res.string.variant_picker_no_pinnable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + state.variantPickerError == "load_failed" -> { Text( text = stringResource(Res.string.variant_picker_load_failed), @@ -220,13 +229,18 @@ private fun VariantOptionList( } items(state.variantPickerOptions, key = { it.id }) { asset -> + // The ViewModel guarantees every asset reaching this list has + // a non-null, non-empty extract — see openVariantPicker's + // pinnableAssets filter. Treat null as a defensive fallback + // and skip the row to keep the dialog tappable everywhere. val variant = AssetVariant.extract(asset.name) - val isCurrent = variant != null && variant.equals(current, ignoreCase = true) + if (variant.isNullOrEmpty()) return@items + val isCurrent = variant.equals(current, ignoreCase = true) VariantRow( isSelected = isCurrent, - title = variant?.takeIf { it.isNotBlank() } ?: asset.name, + title = variant, subtitle = asset.name + " · " + formatBytes(asset.size), - onClick = { variant?.let(onPick) }, + onClick = { onPick(variant) }, ) } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index b45408ec..b4e821c2 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -386,9 +386,16 @@ class DetailsViewModel( siblingAssetCount = installable.size, ) val current = installedApp.preferredAssetVariant - // Avoid hammering the DB / re-check when the user - // tapped the asset that was already pinned. - if (variant != null && !variant.equals(current, ignoreCase = true)) { + val sameVariant = variant != null && variant.equals(current, ignoreCase = true) + // Save when: + // * the user picked a non-null variant AND + // * either it differs from what's currently pinned, + // OR the stale flag is set (re-picking the same + // variant after a stale event must clear the + // flag — otherwise the warning lingers forever) + val shouldSave = + variant != null && (!sameVariant || installedApp.preferredVariantStale) + if (shouldSave) { viewModelScope.launch { try { installedAppsRepository.setPreferredVariant(