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..b7a528ac --- /dev/null +++ b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json @@ -0,0 +1,621 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "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 DEFAULT 0, `preferredAssetVariant` TEXT, `preferredVariantStale` INTEGER NOT NULL DEFAULT 0, 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, + "defaultValue": "0" + }, + { + "fieldPath": "preferredAssetVariant", + "columnName": "preferredAssetVariant", + "affinity": "TEXT" + }, + { + "fieldPath": "preferredVariantStale", + "columnName": "preferredVariantStale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, '8954d1c4c5648189aa99dc390d5b39e3')" + ] + } +} \ 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..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,6 +51,36 @@ 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 — + * 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. + * + * `@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/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..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 @@ -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, @@ -428,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]. 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..e51c3316 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt @@ -0,0 +1,108 @@ +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. 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) + + 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..c7e45e40 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,19 @@ مطابقة في %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..25b61b82 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,19 @@ %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..72b9fdba 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,19 @@ 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. + 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 + 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..cd4b2ef5 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,19 @@ 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. + 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 + 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..d7eb18d5 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,19 @@ %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..77ea87eb 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,19 @@ 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. + 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 + 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..a3464c55 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,19 @@ %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..9f768c7e 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,19 @@ %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..ff9ff81a 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,19 @@ 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. + 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 + 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..e52a9da0 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,19 @@ Совпадение в %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..9a688480 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,19 @@ %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. + 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 + Ö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..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 @@ -619,4 +619,19 @@ 在 %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..101ea12f 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -665,4 +665,19 @@ 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. + 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 + 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..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 @@ -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,132 @@ 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, + ) + // 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 = + pinnableAssets + .map { asset -> asset.toUi() } + .toImmutableList(), + variantPickerError = + when { + preview.matchedAssets.isEmpty() -> "no_assets" + pinnableAssets.isEmpty() -> "no_pinnable_variants" + 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) { + // 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 = + runCatching { installedAppsRepository.getAppByPackage(app.packageName) } + .getOrNull() + ?.toUi() + if (refreshed != null) { + updateSingleApp(refreshed) + } + } + } + } + private fun saveAdvancedSettings() { val app = _state.value.advancedSettingsApp ?: return val draftFilter = _state.value.advancedFilterDraft.trim() @@ -698,8 +858,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 +1508,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 +1567,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..333b1db7 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt @@ -0,0 +1,322 @@ +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 == "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), + 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 -> + // 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) + if (variant.isNullOrEmpty()) return@items + val isCurrent = variant.equals(current, ignoreCase = true) + VariantRow( + isSelected = isCurrent, + title = variant, + subtitle = asset.name + " · " + formatBytes(asset.size), + onClick = { onPick(variant) }, + ) + } + } +} + +@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..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 @@ -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,48 @@ 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 + 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( + 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 +433,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 }