diff --git a/.claude/memory/feedback_coding_boundaries.md b/.claude/memory/feedback_coding_boundaries.md deleted file mode 100644 index fa0a8949..00000000 --- a/.claude/memory/feedback_coding_boundaries.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: coding_boundaries -description: User wants to write all non-trivial logic themselves — Claude should only review, suggest, and handle boilerplate -type: feedback ---- - -Never write the "hard parts" — architecture decisions, core business logic, state management patterns, bug fix implementations, algorithm design. Instead, review the user's code, point out issues, suggest approaches, and explain tradeoffs. Let the user implement it. - -**Why:** The user noticed their coding instincts and skills declining from over-delegating to Claude. They want to stay sharp by doing the thinking and implementation themselves. - -**How to apply:** -- **Hard parts** (user codes): ViewModel logic, repository implementations, state flows, bug fixes, architectural patterns, cache strategies, concurrency handling, UI interaction logic. For these — review, suggest, explain, but don't write the code. -- **Boilerplate** (Claude codes): repetitive refactors, string resources, migration scaffolding, import fixes, build config, copy-paste patterns, test scaffolding, file moves/renames. -- When the user asks to fix a bug or implement a feature, describe what's wrong and suggest an approach — then let them write it. -- If the user explicitly asks "just do it" for something non-trivial, remind them of this agreement first. diff --git a/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/12.json b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/12.json new file mode 100644 index 00000000..640f2262 --- /dev/null +++ b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/12.json @@ -0,0 +1,641 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "439dd162a871645a6e28f0547a3e5cf8", + "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, `preferredAssetTokens` TEXT, `assetGlobPattern` TEXT, `pickedAssetIndex` INTEGER, `pickedAssetSiblingCount` INTEGER, 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" + }, + { + "fieldPath": "preferredAssetTokens", + "columnName": "preferredAssetTokens", + "affinity": "TEXT" + }, + { + "fieldPath": "assetGlobPattern", + "columnName": "assetGlobPattern", + "affinity": "TEXT" + }, + { + "fieldPath": "pickedAssetIndex", + "columnName": "pickedAssetIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "pickedAssetSiblingCount", + "columnName": "pickedAssetSiblingCount", + "affinity": "INTEGER" + } + ], + "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, '439dd162a871645a6e28f0547a3e5cf8')" + ] + } +} \ 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 350a32a8..fab814b5 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 @@ -13,6 +13,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 +import zed.rainxch.core.data.local.db.migrations.MIGRATION_11_12 fun initDatabase(context: Context): AppDatabase { val appContext = context.applicationContext @@ -33,5 +34,6 @@ fun initDatabase(context: Context): AppDatabase { MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, + MIGRATION_11_12, ).build() } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_11_12.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_11_12.kt new file mode 100644 index 00000000..cb7d5539 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_11_12.kt @@ -0,0 +1,31 @@ +package zed.rainxch.core.data.local.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Adds the multi-layer variant fingerprint columns to `installed_apps`: + * + * - `preferredAssetTokens`: serialized token-set fingerprint (closed + * vocabulary of arch / flavor tokens, sorted, joined with `|`) + * - `assetGlobPattern`: glob-pattern fingerprint with version-shaped + * substrings replaced by `*` + * - `pickedAssetIndex`: zero-based index of the picked asset in the + * release's installable-asset list (same-position fallback) + * - `pickedAssetSiblingCount`: total installable assets in the picked + * release, pairs with `pickedAssetIndex` + * + * All four columns are nullable so existing rows keep their current + * single-layer behaviour: the resolver falls back through the layers + * in order, and an old row with only `preferredAssetVariant` set still + * works via the legacy substring-tail match. + */ +val MIGRATION_11_12 = + object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE installed_apps ADD COLUMN preferredAssetTokens TEXT") + db.execSQL("ALTER TABLE installed_apps ADD COLUMN assetGlobPattern TEXT") + db.execSQL("ALTER TABLE installed_apps ADD COLUMN pickedAssetIndex INTEGER") + db.execSQL("ALTER TABLE installed_apps ADD COLUMN pickedAssetSiblingCount INTEGER") + } + } 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 8ea3f272..f7b0c429 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 = 11, + version = 12, 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 0c8e55c4..6bd58541 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 @@ -89,15 +89,23 @@ interface InstalledAppDao { ) /** - * 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. + * Sets the user's preferred asset variant along with its multi-layer + * fingerprint (token set, glob pattern, same-position metadata). + * 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. + * + * Pass `null` for [variant] (and the other fingerprint fields) to + * unpin and fall back to the platform auto-picker. */ @Query( """ UPDATE installed_apps SET preferredAssetVariant = :variant, + preferredAssetTokens = :tokens, + assetGlobPattern = :glob, + pickedAssetIndex = :pickedIndex, + pickedAssetSiblingCount = :siblingCount, preferredVariantStale = 0 WHERE packageName = :packageName """, @@ -105,6 +113,10 @@ interface InstalledAppDao { suspend fun updatePreferredVariant( packageName: String, variant: String?, + tokens: String?, + glob: String?, + pickedIndex: Int?, + siblingCount: Int?, ) /** 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 917a404e..cbdaa896 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 @@ -83,4 +83,42 @@ data class InstalledAppEntity( */ @ColumnInfo(defaultValue = "0") val preferredVariantStale: Boolean = false, + /** + * Token-set fingerprint of the picked asset, serialized as + * `token1|token2|…` (alphabetically sorted so equal sets always + * serialize to identical strings — letting the resolver compare + * with plain string equality instead of parsing). + * + * The token vocabulary is closed (see `AssetVariant.kt`); only + * recognised arch / flavor tokens are stored. `null` means + * "no token fingerprint available — fall back to glob or tail". + */ + val preferredAssetTokens: String? = null, + /** + * Glob-pattern fingerprint of the picked asset (e.g. + * `app-*-arm64-v8a.apk`). Used as a secondary identity layer when + * the token vocabulary doesn't recognise anything in the filename + * — the most common case being custom flavor names. + * + * `null` means the picked filename had no version-shaped substring + * to wildcard, so the glob would just equal the filename and + * provides no rescue value. + */ + val assetGlobPattern: String? = null, + /** + * Zero-based index of the picked asset in the original release's + * installable-asset list. Used by the same-position fallback — + * when none of the fingerprint layers match in a fresh release + * but the new release has exactly the same number of installable + * assets, the asset at this index is preferred. + * + * Weak signal, only consulted as a last resort. `null` for older + * rows pinned before this column existed. + */ + val pickedAssetIndex: Int? = null, + /** + * Total installable assets in the release the user picked from. + * Pairs with [pickedAssetIndex] for same-position fallback. + */ + val pickedAssetSiblingCount: Int? = null, ) 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 06108948..dfb4d15c 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 @@ -42,6 +42,10 @@ fun InstalledApp.toEntity(): InstalledAppEntity = fallbackToOlderReleases = fallbackToOlderReleases, preferredAssetVariant = preferredAssetVariant, preferredVariantStale = preferredVariantStale, + preferredAssetTokens = preferredAssetTokens, + assetGlobPattern = assetGlobPattern, + pickedAssetIndex = pickedAssetIndex, + pickedAssetSiblingCount = pickedAssetSiblingCount, ) fun InstalledAppEntity.toDomain(): InstalledApp = @@ -83,4 +87,8 @@ fun InstalledAppEntity.toDomain(): InstalledApp = fallbackToOlderReleases = fallbackToOlderReleases, preferredAssetVariant = preferredAssetVariant, preferredVariantStale = preferredVariantStale, + preferredAssetTokens = preferredAssetTokens, + assetGlobPattern = assetGlobPattern, + pickedAssetIndex = pickedAssetIndex, + pickedAssetSiblingCount = pickedAssetSiblingCount, ) 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 7d4ae57b..d764227e 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 @@ -149,8 +149,20 @@ class InstalledAppsRepositoryImpl( * Walks [releases] (already in newest-first order) and returns the first * release whose installable asset list — after applying [filter] — yields * a usable asset. The picker tries, in order: - * 1. The user's [preferredVariant] (if set) - * 2. The platform installer's architecture-aware auto-pick + * + * 1. **Token-set match** — pinned token fingerprint equals the asset's + * 2. **Glob match** — pinned glob pattern equals the asset's + * 3. **Tail-string match** — legacy substring-tail equality + * 4. **Same-position fallback** — same index, same total count of + * installable assets as when the user originally pinned + * 5. **Platform auto-pick** — architecture-aware default + * + * Layers 1–3 are wrapped behind [AssetVariant.resolvePreferredAsset]. + * Layer 4 is consulted only when 1–3 all miss but the new release + * has exactly the same number of installable assets as the picked + * release. Layer 5 keeps updates flowing even when the variant is + * completely lost — the caller flips `variantWasLost` so the UI + * can surface the discrepancy. * * When [filter] is null, only the first release in the window is * considered: this preserves the pre-existing behaviour for apps that @@ -167,6 +179,10 @@ class InstalledAppsRepositoryImpl( filter: AssetFilter?, fallbackToOlderReleases: Boolean, preferredVariant: String?, + preferredTokens: Set, + preferredGlob: String?, + pickedIndex: Int?, + pickedSiblingCount: Int?, ): ResolvedRelease? { if (releases.isEmpty()) return null @@ -177,6 +193,15 @@ class InstalledAppsRepositoryImpl( releases.take(1) } + // "Has any pin" tracks whether the user has *something* stored + // for variant identity — used to decide whether the + // `variantWasLost` flag should flip on. Without this, an app + // that's never been pinned would always look "lost". + val hasAnyPin = + preferredVariant != null || + preferredTokens.isNotEmpty() || + !preferredGlob.isNullOrBlank() + for (release in candidates) { val installableForPlatform = release.assets.filter { installer.isAssetInstallable(it.name) } @@ -186,17 +211,43 @@ class InstalledAppsRepositoryImpl( if (installableForApp.isEmpty()) continue - // 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 + // Layers 1–3: token set, glob, then legacy tail string. + val fingerprintMatch = + AssetVariant.resolvePreferredAsset( + assets = installableForApp, + pinnedVariant = preferredVariant, + pinnedTokens = preferredTokens.takeIf { it.isNotEmpty() }, + pinnedGlob = preferredGlob, + ) + + // Layer 4: same-position fallback. Only consulted when no + // fingerprint matched and the user actually pinned + // *something* (otherwise the index is meaningless). + val positionMatch = + if (fingerprintMatch == null && hasAnyPin) { + AssetVariant.resolveBySamePosition( + assets = installableForApp, + originalIndex = pickedIndex, + siblingCountAtPickTime = pickedSiblingCount, + ) + } else { + null + } + + // Layer 5: platform auto-pick (last resort, never null + // unless the platform installer can't pick anything). + val primary = fingerprintMatch + ?: positionMatch ?: installer.choosePrimaryAsset(installableForApp) ?: continue - val variantWasLost = preferredVariant != null && variantMatch == null + + // The variant is "lost" when the user had a pin but neither + // a fingerprint nor a same-position match recovered it. + // Same-position rescues silently (it's a confidence-trick + // — the user can't tell anything went wrong) so we don't + // flag it as lost; otherwise the UI would nag every check. + val variantWasLost = + hasAnyPin && fingerprintMatch == null && positionMatch == null return ResolvedRelease(release, primary, variantWasLost) } @@ -241,6 +292,10 @@ class InstalledAppsRepositoryImpl( filter = compiledFilter, fallbackToOlderReleases = app.fallbackToOlderReleases, preferredVariant = app.preferredAssetVariant, + preferredTokens = AssetVariant.deserializeTokens(app.preferredAssetTokens), + preferredGlob = app.assetGlobPattern, + pickedIndex = app.pickedAssetIndex, + pickedSiblingCount = app.pickedAssetSiblingCount, ) if (resolved == null) { @@ -418,11 +473,21 @@ class InstalledAppsRepositoryImpl( override suspend fun setPreferredVariant( packageName: String, variant: String?, + tokens: String?, + glob: String?, + pickedIndex: Int?, + siblingCount: Int?, ) { - val normalized = variant?.trim()?.takeIf { it.isNotEmpty() } + val normalizedVariant = variant?.trim()?.takeIf { it.isNotEmpty() } + val normalizedTokens = tokens?.trim()?.takeIf { it.isNotEmpty() } + val normalizedGlob = glob?.trim()?.takeIf { it.isNotEmpty() } installedAppsDao.updatePreferredVariant( packageName = packageName, - variant = normalized, + variant = normalizedVariant, + tokens = normalizedTokens, + glob = normalizedGlob, + pickedIndex = pickedIndex, + siblingCount = siblingCount?.takeIf { it > 0 }, ) // Re-run the update check so cached `latestAsset*` columns point @@ -440,6 +505,17 @@ class InstalledAppsRepositoryImpl( } } + override suspend fun clearPreferredVariant(packageName: String) { + setPreferredVariant( + packageName = packageName, + variant = null, + tokens = null, + glob = null, + pickedIndex = null, + siblingCount = null, + ) + } + 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 a85fe1d5..224c9203 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 @@ -15,6 +15,13 @@ data class ExportedApp( // Preferred-variant tracking (added in export schema v3). Defaults // keep older exports decoding without changes. val preferredAssetVariant: String? = null, + // Multi-layer variant fingerprint (added in export schema v4): + // serialized token set, glob pattern, and same-position fallback + // metadata. All optional so older v1/v2/v3 exports still decode. + val preferredAssetTokens: String? = null, + val assetGlobPattern: String? = null, + val pickedAssetIndex: Int? = null, + val pickedAssetSiblingCount: Int? = null, ) @Serializable @@ -23,11 +30,13 @@ data class ExportedAppList( * Export schema version. * - v2: added [ExportedApp.assetFilterRegex] / [ExportedApp.fallbackToOlderReleases] * - v3: added [ExportedApp.preferredAssetVariant] + * - v4: added [ExportedApp.preferredAssetTokens] / [ExportedApp.assetGlobPattern] + * / [ExportedApp.pickedAssetIndex] / [ExportedApp.pickedAssetSiblingCount] * * All older versions still decode correctly because the new fields * have safe defaults. */ - val version: Int = 3, + val version: Int = 4, 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 e4cee06e..a643a98b 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 @@ -62,4 +62,32 @@ data class InstalledApp( * a new variant. */ val preferredVariantStale: Boolean = false, + /** + * Token-set fingerprint of the picked asset, serialized via + * `AssetVariant.serializeTokens` (sorted, joined by `|`). Primary + * identity layer for the resolver — handles arch-before-version, + * OS-version interlopers, and counters between version and arch. + * + * `null` for older rows pinned before this column existed and for + * filenames where the token vocabulary recognises nothing. + */ + val preferredAssetTokens: String? = null, + /** + * Glob-pattern fingerprint of the picked asset (e.g. + * `app-*-arm64-v8a.apk`). Secondary identity layer used when the + * token vocabulary doesn't recognise anything in the filename — + * the most common case being custom flavor names. + */ + val assetGlobPattern: String? = null, + /** + * Zero-based index of the picked asset in the original release's + * installable-asset list. Last-resort same-position fallback when + * none of the fingerprint layers match in a fresh release. + */ + val pickedAssetIndex: Int? = null, + /** + * Total installable assets in the release the user picked from. + * Pairs with [pickedAssetIndex] for the same-position fallback. + */ + val pickedAssetSiblingCount: Int? = null, ) 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 74437d1e..c2728b35 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 @@ -66,20 +66,44 @@ interface InstalledAppsRepository { ) /** - * 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. + * Persists the user's preferred asset variant for [packageName] + * along with the full multi-layer fingerprint: * - * Implementations should re-check the app for updates immediately so - * the cached `latestAsset*` fields point at the variant the user + * - [variant]: legacy substring-tail label, used as the display + * name and as a third-tier match in the resolver + * - [tokens]: serialized token-set fingerprint (primary identity) + * - [glob]: glob-pattern fingerprint (secondary identity) + * - [pickedIndex]: zero-based index of the picked asset in the + * release's installable-asset list (same-position fallback) + * - [siblingCount]: total installable assets in the picked release + * + * Always clears the `preferredVariantStale` flag in the same write + * because the user has just made an explicit choice. + * + * Pass `null` for all fields except [packageName] to unpin and fall + * back to the platform auto-picker — convenient via [clearPreferredVariant]. + * + * 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?, + tokens: String? = null, + glob: String? = null, + pickedIndex: Int? = null, + siblingCount: Int? = null, ) + /** + * Convenience for [setPreferredVariant] that clears every + * fingerprint layer for [packageName] in a single call. The + * resolver will fall back to the platform auto-picker on the next + * update check. + */ + suspend fun clearPreferredVariant(packageName: 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 index e51c3316..faac2ad3 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 @@ -3,66 +3,282 @@ 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. + * Identifies the "variant" of a GitHub release asset — the part of the + * filename that stays stable across releases (architecture, packaging + * flavour, etc.) so the user's choice can survive a version bump. * - * 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) + * # Why this is non-trivial * - * 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. + * Release filenames are wildly inconsistent. The naïve approach + * (substring after the version segment) breaks on a handful of common + * shapes: + * + * - **Arch-before-version**: `app-arm64-v8a-1.2.3.apk` — version is at + * the end, the variant token is in front of it + * - **Counter between version and arch**: `app-1.2.3-beta.1-arm64-v8a.apk` + * — substring tail would include `beta.1`, drifting release-over-release + * - **Version-code-only naming**: `app_1234_arm64-v8a.apk` — no dotted + * version anchor + * - **OS-version interlopers**: `app-android-12.0-1.2.3-arm64.apk` — the + * OS version is the first dotted-digit substring + * + * # The strategy + * + * Three layers of identity, each cheaper to compute than the next, all + * consulted when matching against a fresh release: + * + * 1. **Token-set fingerprint** ([extractTokens]): pulls a *set* of + * well-known arch / flavor / qualifier tokens out of the filename + * regardless of position. Two filenames are "the same variant" if + * their token sets are equal. This is the primary identity and + * handles arch-before-version, OS-version interlopers, and counters + * in one go. + * + * 2. **Glob-pattern fingerprint** ([deriveGlob]): replaces version-shaped + * substrings with `*` (and digit-only run substrings with `*`), + * yielding e.g. `app-*-arm64-v8a.apk` from `app-1.2.3-arm64-v8a.apk`. + * Used as a secondary key when the token vocabulary doesn't recognise + * anything in the filename — covers custom flavor names like + * `myapp-foss-1.2.3.apk` → `myapp-foss-*.apk`. + * + * 3. **Substring-tail extract** ([extract]): the legacy fallback. Kept + * for surface compatibility with the existing UI (badge labels) and + * because it's a cheap way to display *something* readable to the + * user when the token vocabulary returns nothing. + * + * # Vocabulary policy + * + * The token vocabulary is intentionally a closed set. Open-ended token + * extraction (e.g. "anything that's not a digit run") leaks app names, + * release qualifiers, and date components into the variant identity, + * which is exactly what the substring-tail approach gets wrong. The + * vocabulary covers the tokens that actually distinguish APK variants + * in the wild — architectures, install flavors, signing qualifiers. + * + * Casing is normalised to lowercase before comparison; some maintainers + * flip casing release-over-release. */ 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 + * Closed vocabulary of tokens that are meaningful for variant + * identity. Anything *not* in this set is considered noise (app name, + * release qualifier, date component, etc.) and is ignored. + * + * Architecture tokens dominate the list because that's the most + * common variant axis on Android. Flavor tokens (`fdroid`, `play`, + * `foss`, `gms`, `nogms`, `huawei`) cover the second-most-common + * axis: same arch, different distribution channel. + * + * `release` and `signed` are explicitly excluded — they appear in + * a lot of filenames but rarely *distinguish* assets within a + * single release. Including them would create false negatives + * when one release uses `release-arm64-v8a` and the next uses + * `arm64-v8a`. Same reasoning applies to `aligned`, `unsigned`. */ - private val VERSION_SEGMENT = - Regex("[-_ ]v?\\d+(?:\\.\\d+)+(?=[-_. ]|$)", RegexOption.IGNORE_CASE) + private val ARCH_TOKENS = + setOf( + // 64-bit ARM (the modern default on Android) + "arm64-v8a", + "arm64", + "aarch64", + // 32-bit ARM + "armeabi-v7a", + "armeabi", + "armv7", + "armv7a", + "armv8", + // x86 family + "x86_64", + "x86-64", + "x64", + "x86", + "i386", + "i686", + // MIPS (rare but real on legacy hardware) + "mips", + "mips64", + // Universal / fat APKs + "universal", + "all", + ) - private val LEADING_SEPARATORS = charArrayOf('-', '_', ' ', '.') + private val FLAVOR_TOKENS = + setOf( + // Distribution channels + "fdroid", + "f-droid", + "play", + "playstore", + "googleplay", + "gms", + "nogms", + "huawei", + "amazon", + "samsung", + // Build flavors + "foss", + "libre", + "free", + "pro", + "premium", + "full", + "lite", + "beta", + "stable", + "canary", + "nightly", + ) + + /** + * The full vocabulary used for token-set comparison. Lazily merged + * because the JVM `Set` union allocates and there's no reason to do + * it on every call. + */ + private val VOCABULARY: Set by lazy { ARCH_TOKENS + FLAVOR_TOKENS } + + /** + * Splits a filename into candidate tokens. Splits on the usual + * separators (`-`, `_`, ` `, `.`) so that compound names like + * `arm64-v8a` survive intact only after the recombine step below. + * + * Note: tokens are *normalised to lowercase* and the file extension + * is stripped first. + */ + private fun tokenize(assetName: String): List { + val withoutExt = assetName.substringBeforeLast('.') + return withoutExt + .lowercase() + .split('-', '_', ' ', '.') + .filter { it.isNotEmpty() } + } + /** + * Extracts the **token-set fingerprint** of [assetName]. Returns the + * subset of [VOCABULARY] that appears in the filename, accounting for + * compound tokens that span the splitter (e.g. `arm64-v8a` is + * tokenised as `["arm64", "v8a"]` but recognised as the compound + * `arm64-v8a` via a sliding-window check). + * + * Two assets share the same variant identity iff [extractTokens] + * returns equal sets. + * + * Returns an empty set when the filename contains no recognisable + * tokens — that's a deliberate "no token-based identity available, + * fall through to the next layer" signal. + */ + fun extractTokens(assetName: String): Set { + val tokens = tokenize(assetName) + if (tokens.isEmpty()) return emptySet() + + val found = mutableSetOf() + + // First pass: 3-grams, 2-grams, then 1-grams. Order matters + // because longer matches should take precedence: matching + // `armeabi-v7a` should not also match the bare `armeabi` + // afterwards (they're the same conceptual variant in different + // tokenisations of the maintainer's filename). + val consumed = BooleanArray(tokens.size) + + // 3-grams (rare but `armeabi-v7a-release` style exists) + for (i in 0 until tokens.size - 2) { + if (consumed[i] || consumed[i + 1] || consumed[i + 2]) continue + val candidate = "${tokens[i]}-${tokens[i + 1]}-${tokens[i + 2]}" + if (candidate in VOCABULARY) { + found += candidate + consumed[i] = true + consumed[i + 1] = true + consumed[i + 2] = true + } + } + + // 2-grams: `arm64-v8a`, `armeabi-v7a`, `x86-64`, `x86_64` + // The tokenizer split on both `-` and `_`, so `x86_64` becomes + // `["x86", "64"]`; we recombine with `-` for vocabulary lookup + // *and* try the underscore form to cover `x86_64`. Both forms + // appear in the wild from different maintainers. + for (i in 0 until tokens.size - 1) { + if (consumed[i] || consumed[i + 1]) continue + val dashed = "${tokens[i]}-${tokens[i + 1]}" + val underscored = "${tokens[i]}_${tokens[i + 1]}" + val match = + when { + dashed in VOCABULARY -> dashed + underscored in VOCABULARY -> underscored + else -> null + } + if (match != null) { + found += match + consumed[i] = true + consumed[i + 1] = true + } + } + + // 1-grams: bare tokens + for (i in tokens.indices) { + if (consumed[i]) continue + if (tokens[i] in VOCABULARY) { + found += tokens[i] + consumed[i] = true + } + } + + return found + } + + /** + * Derives a **glob-pattern fingerprint** from [assetName]: replaces + * any dotted-digit version segment and any standalone digit run with + * `*`, leaving everything else intact. Used as a secondary identity + * when [extractTokens] returns an empty set. + * + * Examples: + * + * `app-1.2.3-arm64-v8a.apk` → `app-*-arm64-v8a.apk` + * `myapp-foss-1.2.3.apk` → `myapp-foss-*.apk` + * `Project_v2.0.1_universal.apk` → `project_v*_universal.apk` + * `release-2024.04.10-debug.apk` → `release-*-debug.apk` + * `app_1234_arm64.apk` → `app_*_arm64.apk` + * + * The result is also lowercased so two maintainers with different + * casing conventions still produce the same fingerprint. + * + * Returns `null` when the filename has no version-shaped or + * digit-run substring at all — there'd be nothing to wildcard, so + * the glob would just equal the filename and provides no rescue + * value beyond exact-match. + */ + fun deriveGlob(assetName: String): String? { + val lower = assetName.lowercase() + // Match either: + // - a versioned segment with at least one dot (e.g. `1.2.3`, + // `v2.0.1`, `2024.04.10`) + // - OR a standalone digit run of length >= 2 (the `1234` + // version-code-only case). Length >= 2 avoids replacing + // legitimate single-digit tokens like `v8` in `arm64-v8a`. + val versionPattern = + Regex("""v?\d+(?:\.\d+)+|(?, - preferredVariant: String?, + pinnedVariant: String?, + pinnedTokens: Set? = null, + pinnedGlob: String? = null, ): GithubAsset? { - val target = preferredVariant?.trim()?.takeIf { it.isNotBlank() } ?: return null + // Layer 1: token-set match. The strongest signal — survives + // arch-before-version, OS-version interlopers, and counters. + if (!pinnedTokens.isNullOrEmpty()) { + val match = assets.firstOrNull { asset -> + extractTokens(asset.name) == pinnedTokens + } + if (match != null) return match + } + + // Layer 2: glob match. Catches custom flavor names that the + // closed token vocabulary doesn't know about. + if (!pinnedGlob.isNullOrBlank()) { + val match = assets.firstOrNull { asset -> + deriveGlob(asset.name) == pinnedGlob + } + if (match != null) return match + } + + // Layer 3: legacy tail-string match. Keeps rows pinned before + // the multi-layer rewrite working without forcing a re-pick. + val target = pinnedVariant?.trim()?.takeIf { it.isNotBlank() } ?: return null return assets.firstOrNull { asset -> extract(asset.name)?.equals(target, ignoreCase = true) == true } } + /** + * Same-position fallback used by the resolver as a last resort + * before falling back to the platform auto-picker. When the new + * release contains exactly the same number of installable assets + * as the original picked-from release, returning the asset at the + * same index preserves the user's intent in cases where every + * asset has been renamed in lockstep (e.g. an entire flavor + * dimension was added). + * + * Returns `null` when [siblingCountAtPickTime] is null, zero, + * or doesn't match `assets.size`. + */ + fun resolveBySamePosition( + assets: List, + originalIndex: Int?, + siblingCountAtPickTime: Int?, + ): GithubAsset? { + if (originalIndex == null || siblingCountAtPickTime == null) return null + if (siblingCountAtPickTime <= 0) return null + if (assets.size != siblingCountAtPickTime) return null + return assets.getOrNull(originalIndex) + } + /** * 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. * + * The returned tail is the **display label** that gets stored in + * `InstalledApp.preferredAssetVariant` and shown to the user. The + * matching algorithm itself uses [extractTokens] / [deriveGlob] — + * those are also derived at pin time and stored separately. + * * Single-asset releases and "no variant suffix" filenames both return * `null` rather than the empty string — there's nothing to pin. */ @@ -105,4 +396,61 @@ object AssetVariant { val variant = extract(pickedAssetName) ?: return null return variant.takeIf { it.isNotEmpty() } } + + /** + * Bundle of all three identity layers derived from a single picked + * asset filename. Used by the persistence path so the caller can + * write all fingerprints atomically and the resolver can match on + * any of them later. + * + * - [variant]: legacy substring tail (display label) + * - [tokens]: token-set fingerprint, empty when the filename has + * no vocabulary tokens + * - [glob]: glob-pattern fingerprint, null when there's no + * version-shaped substring to wildcard + * + * Returns `null` when [siblingAssetCount] <= 1 (nothing to pin) or + * when *all three* identity layers came up empty — at that point + * the asset has no stable fingerprint and pinning would be a lie. + */ + data class VariantFingerprint( + val variant: String?, + val tokens: Set, + val glob: String?, + ) + + fun fingerprintFromPickedAsset( + pickedAssetName: String, + siblingAssetCount: Int, + ): VariantFingerprint? { + if (siblingAssetCount <= 1) return null + val variant = extract(pickedAssetName)?.takeIf { it.isNotEmpty() } + val tokens = extractTokens(pickedAssetName) + val glob = deriveGlob(pickedAssetName) + // If everything is empty there's nothing to remember — return + // null so callers persist `null` and fall back to the platform + // auto-picker on update. + if (variant == null && tokens.isEmpty() && glob == null) return null + return VariantFingerprint(variant = variant, tokens = tokens, glob = glob) + } + + /** + * Serializes a token set to a stable string for storage. Sorted so + * that identical sets always serialize to identical strings, which + * is what makes string equality a valid set-equality check at the + * SQL layer (avoiding a JSON column or a join table). + * + * Format: tokens joined by `|`. Returns `null` for empty sets so + * the column can stay nullable and the resolver knows when there's + * no token fingerprint to compare against. + */ + fun serializeTokens(tokens: Set): String? { + if (tokens.isEmpty()) return null + return tokens.sorted().joinToString("|") + } + + fun deserializeTokens(serialized: String?): Set { + if (serialized.isNullOrBlank()) return emptySet() + return serialized.split('|').filter { it.isNotBlank() }.toSet() + } } 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 c7e45e40..4293df86 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -673,4 +673,12 @@ مثبت على: %1$s تغير النوع — انقر تحديث للاختيار مرة أخرى النوع: %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 25b61b82..86d1d843 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -672,4 +672,12 @@ পিন করা: %1$s ভ্যারিয়েন্ট পরিবর্তিত — আবার বাছাই করতে আপডেট ট্যাপ করুন ভ্যারিয়েন্ট: %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 72b9fdba..98be01fa 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -633,4 +633,12 @@ Fijada en: %1$s Variante cambiada — toca Actualizar para elegir de nuevo Variante: %1$s + Desfijar + Fijada + Elegir variante + Ajustar filtro + Las próximas actualizaciones preferirán la variante %1$s. Cámbialo cuando quieras desde los ajustes de la app. + Las próximas actualizaciones preferirán esta variante. Cámbialo cuando quieras desde los ajustes de la app. + Variante desfijada. Las actualizaciones usarán el selector automático. + El filtro decide qué archivos se consideran. La variante fijada decide cuál de ellos se instala. \ 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 cd4b2ef5..2b520087 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -634,4 +634,12 @@ Épinglée à : %1$s Variante changée — appuyez sur Mettre à jour pour choisir à nouveau Variante : %1$s + Désépingler + Épinglée + Choisir la variante + Ajuster le filtre + Les futures mises à jour préféreront la variante %1$s. Modifiable à tout moment dans les paramètres de l\'application. + Les futures mises à jour préféreront cette variante. Modifiable à tout moment dans les paramètres de l\'application. + Variante désépinglée. Les mises à jour utiliseront le sélecteur automatique. + Le filtre décide quels fichiers sont pris en compte. L\'épingle de variante décide lequel sera installé. \ 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 d7eb18d5..63622dd6 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -671,4 +671,12 @@ पिन किया गया: %1$s वेरिएंट बदला — फिर से चुनने के लिए अपडेट टैप करें वेरिएंट: %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 77ea87eb..6c627a60 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -672,4 +672,12 @@ Bloccata su: %1$s Variante cambiata — tocca Aggiorna per scegliere di nuovo Variante: %1$s + Sblocca + Bloccata + Scegli variante + Modifica filtro + I prossimi aggiornamenti preferiranno la variante %1$s. Puoi modificarlo in qualsiasi momento dalle impostazioni dell\'app. + I prossimi aggiornamenti preferiranno questa variante. Puoi modificarlo in qualsiasi momento dalle impostazioni dell\'app. + Variante sbloccata. Gli aggiornamenti useranno il selettore automatico. + Il filtro decide quali file vengono considerati. Il blocco della variante decide quale di essi viene installato. \ 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 a3464c55..93b99c6d 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -633,4 +633,12 @@ 固定中: %1$s バリアントが変更されました — アップデートをタップして再選択 バリアント: %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 9f768c7e..dc38e485 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -668,4 +668,12 @@ 고정됨: %1$s 변형이 변경되었습니다 — 업데이트를 탭하여 다시 선택하세요 변형: %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 ff9ff81a..a3d72094 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -640,4 +640,12 @@ Przypięty do: %1$s Wariant zmieniony — naciśnij Aktualizuj, aby wybrać ponownie Wariant: %1$s + Odepnij + Przypięty + Wybierz wariant + Dostosuj filtr + Przyszłe aktualizacje będą preferować wariant %1$s. Możesz to zmienić w dowolnym momencie w ustawieniach aplikacji. + Przyszłe aktualizacje będą preferować ten wariant. Możesz to zmienić w dowolnym momencie w ustawieniach aplikacji. + Wariant odpięty. Aktualizacje będą używać automatycznego selektora. + Filtr decyduje, które pliki są brane pod uwagę. Przypięty wariant decyduje, który z nich zostanie zainstalowany. \ 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 e52a9da0..e9bd72bb 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -640,4 +640,12 @@ Закреплено: %1$s Вариант изменился — нажмите Обновить, чтобы выбрать снова Вариант: %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 9a688480..82457eaa 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -670,4 +670,12 @@ Sabitlendi: %1$s Varyant değişti — tekrar seçmek için Güncelle\'ye dokunun Varyant: %1$s + Sabitlemeyi kaldır + Sabitlendi + Varyant seç + Filtreyi düzenle + Sonraki güncellemeler %1$s varyantını tercih edecek. Bunu uygulama ayarlarından istediğiniz zaman değiştirebilirsiniz. + Sonraki güncellemeler bu varyantı tercih edecek. Bunu uygulama ayarlarından istediğiniz zaman değiştirebilirsiniz. + Varyant sabitlemesi kaldırıldı. Güncellemeler otomatik seçiciyi kullanacak. + Filtre hangi dosyaların dikkate alınacağına karar verir. Varyant sabitlemesi bunlardan hangisinin yükleneceğine karar verir. 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 71c6e268..45fb5b56 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 @@ -634,4 +634,12 @@ 已固定为:%1$s 变体已更改 — 点击更新以重新选择 变体:%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 101ea12f..4c8d0484 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -680,4 +680,13 @@ Pinned to: %1$s Variant changed — tap Update to pick again Variant: %1$s + + Unpin + Pinned + Pick variant + Adjust filter + Future updates will prefer the %1$s variant. Change this anytime in app settings. + Future updates will prefer this variant. Change this anytime in app settings. + Variant unpinned. Updates will use the automatic picker. + The filter decides which assets are considered. The variant pin decides which of those assets gets installed. 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 51af6edc..cccebd24 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 @@ -157,19 +157,38 @@ class AppsRepositoryImpl( pickedAssetName: String?, pickedAssetSiblingCount: Int, preferredAssetVariant: String?, + preferredAssetTokens: String?, + assetGlobPattern: String?, + pickedAssetIndex: Int?, ) { 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 = + // Pre-derived fingerprint (from import) wins over re-deriving + // from the picked filename. Falls through to deriving fresh + // from the picked asset when there's nothing pre-computed. + val derivedVariant = preferredAssetVariant?.trim()?.takeIf { it.isNotEmpty() } ?: pickedAssetName?.let { AssetVariant.deriveFromPickedAsset(it, pickedAssetSiblingCount) } + // Multi-layer fingerprint: when coming from import, we already + // have these stored from the prior install. When coming from + // the link sheet picker, derive them fresh from the picked + // asset's filename so all three identity layers are populated + // atomically with the rest of the row. + val freshFingerprint = + if (preferredAssetTokens == null && assetGlobPattern == null && pickedAssetName != null) { + AssetVariant.fingerprintFromPickedAsset(pickedAssetName, pickedAssetSiblingCount) + } else { + null + } + val resolvedTokens = preferredAssetTokens + ?: freshFingerprint?.tokens?.let { AssetVariant.serializeTokens(it) } + val resolvedGlob = assetGlobPattern ?: freshFingerprint?.glob + val resolvedSiblingCount = pickedAssetSiblingCount.takeIf { it > 0 } + val installedApp = InstalledApp( packageName = deviceApp.packageName, @@ -204,8 +223,12 @@ class AppsRepositoryImpl( includePreReleases = globalPreRelease, assetFilterRegex = normalizedFilter, fallbackToOlderReleases = fallbackToOlderReleases, - preferredAssetVariant = resolvedVariant, + preferredAssetVariant = derivedVariant, preferredVariantStale = false, + preferredAssetTokens = resolvedTokens, + assetGlobPattern = resolvedGlob, + pickedAssetIndex = pickedAssetIndex, + pickedAssetSiblingCount = resolvedSiblingCount, ) appsRepository.saveInstalledApp(installedApp) @@ -215,7 +238,7 @@ class AppsRepositoryImpl( val apps = appsRepository.getAllInstalledApps().first() val exported = ExportedAppList( - version = 3, + version = 4, exportedAt = Clock.System.now().toEpochMilliseconds(), apps = apps.map { app -> @@ -227,6 +250,10 @@ class AppsRepositoryImpl( assetFilterRegex = app.assetFilterRegex, fallbackToOlderReleases = app.fallbackToOlderReleases, preferredAssetVariant = app.preferredAssetVariant, + preferredAssetTokens = app.preferredAssetTokens, + assetGlobPattern = app.assetGlobPattern, + pickedAssetIndex = app.pickedAssetIndex, + pickedAssetSiblingCount = app.pickedAssetSiblingCount, ) }, ) @@ -276,7 +303,11 @@ class AppsRepositoryImpl( repoInfo = repoInfo, assetFilterRegex = exportedApp.assetFilterRegex, fallbackToOlderReleases = exportedApp.fallbackToOlderReleases, + pickedAssetSiblingCount = exportedApp.pickedAssetSiblingCount ?: 0, preferredAssetVariant = exportedApp.preferredAssetVariant, + preferredAssetTokens = exportedApp.preferredAssetTokens, + assetGlobPattern = exportedApp.assetGlobPattern, + pickedAssetIndex = exportedApp.pickedAssetIndex, ) 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 bf5b3c30..a6e63ad2 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 @@ -54,6 +54,23 @@ interface AppsRepository { * than a fresh asset filename to extract from. */ preferredAssetVariant: String? = null, + /** + * Pre-derived multi-layer fingerprint from a previous export. + * Takes precedence over deriving from [pickedAssetName] when + * non-null — preserves the exact identity layers the user + * pinned in their other install rather than recomputing from + * a possibly-different asset list. + */ + preferredAssetTokens: String? = null, + assetGlobPattern: String? = null, + /** + * Zero-based index of the picked asset in the release's + * installable-asset list. Stored for the same-position fallback + * — when the resolver can't match any fingerprint layer in a + * fresh release but the new release has the same number of + * installable assets, this index is preferred. + */ + pickedAssetIndex: Int? = null, ) suspend fun exportApps(): String 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 b92de12a..6dd38bd7 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 @@ -2,6 +2,7 @@ package zed.rainxch.apps.presentation +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,6 +29,7 @@ import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Update @@ -104,6 +106,7 @@ 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_picker_open 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 @@ -531,6 +534,14 @@ fun AppsScreen( onAdvancedSettingsClick = { onAction(AppsAction.OnOpenAdvancedSettings(appItem.installedApp)) }, + onPickVariantClick = { + onAction( + AppsAction.OnOpenVariantPicker( + app = appItem.installedApp, + resumeUpdateAfterPick = false, + ), + ) + }, modifier = Modifier .then( @@ -623,6 +634,7 @@ fun AppItemCard( onRepoClick: () -> Unit, onTogglePreReleases: (Boolean) -> Unit, onAdvancedSettingsClick: () -> Unit, + onPickVariantClick: () -> Unit, modifier: Modifier = Modifier, ) { val app = appItem.installedApp @@ -708,6 +720,11 @@ fun AppItemCard( text = stringResource(Res.string.variant_stale_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, + modifier = + Modifier.clickable( + enabled = !isBusy, + onClick = onPickVariantClick, + ), ) } @@ -811,6 +828,32 @@ fun AppItemCard( ) } + // Always-visible "Pick variant" entry point. Tints to + // primary when a variant is pinned (so users can see + // at a glance whether the app has a sticky pick) and + // to error when the pinned variant has gone stale. + val pickVariantDescription = + stringResource(Res.string.variant_picker_open) + val hasPin = !app.preferredAssetVariant.isNullOrBlank() + IconButton( + onClick = onPickVariantClick, + enabled = !isBusy, + modifier = Modifier.semantics { + contentDescription = pickVariantDescription + }, + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = + when { + app.preferredVariantStale -> MaterialTheme.colorScheme.error + hasPin -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + Checkbox( checked = app.includePreReleases, onCheckedChange = onTogglePreReleases, 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 23131fd0..26ac57d5 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 @@ -868,7 +868,9 @@ class AppsViewModel( val variantMatch = AssetVariant.resolvePreferredAsset( assets = installableAssets, - preferredVariant = app.preferredAssetVariant, + pinnedVariant = app.preferredAssetVariant, + pinnedTokens = AssetVariant.deserializeTokens(app.preferredAssetTokens), + pinnedGlob = app.assetGlobPattern, ) val primaryAsset = variantMatch 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 df1a4533..470a8574 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 @@ -189,6 +189,19 @@ fun AdvancedAppSettingsBottomSheet( // 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. + // + // Cross-link copy: a one-liner above the row clarifies + // the *relationship* between the filter (which assets are + // even considered) and the variant pin (which of the + // matching assets gets installed) — these are the two + // axes a user is actually adjusting when they wonder why + // an update grabbed the wrong file. + Text( + text = stringResource(Res.string.advanced_filter_variant_relation), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) VariantRow( pinnedVariant = app.preferredAssetVariant, isStale = app.preferredVariantStale, 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 333b1db7..11d334ad 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 @@ -155,6 +155,19 @@ fun VariantPickerDialog( } }, confirmButton = { + // Cross-link: jump to the asset filter sheet for this app. + // The two are conceptually adjacent — filter decides which + // assets are *considered*, the variant picker decides which + // of the matching assets gets installed. Users debugging + // "wrong file installed" need both within reach. + TextButton( + onClick = { + onAction(AppsAction.OnDismissVariantPicker) + onAction(AppsAction.OnOpenAdvancedSettings(app)) + }, + ) { + Text(stringResource(Res.string.variant_picker_open_filter)) + } TextButton(onClick = { onAction(AppsAction.OnDismissVariantPicker) }) { Text(stringResource(Res.string.cancel)) } 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 c292b849..f587aea4 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 @@ -42,6 +42,10 @@ fun InstalledApp.toUi(): InstalledAppUi = fallbackToOlderReleases = fallbackToOlderReleases, preferredAssetVariant = preferredAssetVariant, preferredVariantStale = preferredVariantStale, + preferredAssetTokens = preferredAssetTokens, + assetGlobPattern = assetGlobPattern, + pickedAssetIndex = pickedAssetIndex, + pickedAssetSiblingCount = pickedAssetSiblingCount, ) fun InstalledAppUi.toDomain(): InstalledApp = @@ -83,4 +87,8 @@ fun InstalledAppUi.toDomain(): InstalledApp = fallbackToOlderReleases = fallbackToOlderReleases, preferredAssetVariant = preferredAssetVariant, preferredVariantStale = preferredVariantStale, + preferredAssetTokens = preferredAssetTokens, + assetGlobPattern = assetGlobPattern, + pickedAssetIndex = pickedAssetIndex, + pickedAssetSiblingCount = pickedAssetSiblingCount, ) 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 f56a9c83..8802e0cd 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 @@ -40,4 +40,8 @@ data class InstalledAppUi( val fallbackToOlderReleases: Boolean = false, val preferredAssetVariant: String? = null, val preferredVariantStale: Boolean = false, + val preferredAssetTokens: String? = null, + val assetGlobPattern: String? = null, + val pickedAssetIndex: Int? = null, + val pickedAssetSiblingCount: Int? = null, ) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index adc297b3..35d0fb88 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -102,4 +102,12 @@ sealed interface DetailsAction { ) : DetailsAction data object ToggleReleaseAssetsPicker : DetailsAction + + /** + * Clears the user's preferred variant pin for the currently-tracked + * app. Falls back to the platform auto-picker on subsequent updates. + * Triggered by the "Unpin variant" affordance in the asset picker + * sheet. + */ + data object UnpinPreferredVariant : DetailsAction } 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 b4e821c2..7379706c 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 @@ -72,6 +72,9 @@ import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded import zed.rainxch.githubstore.core.presentation.res.removed_from_favourites import zed.rainxch.githubstore.core.presentation.res.translation_failed import zed.rainxch.githubstore.core.presentation.res.update_package_mismatch +import zed.rainxch.githubstore.core.presentation.res.variant_first_pin_toast +import zed.rainxch.githubstore.core.presentation.res.variant_first_pin_toast_generic +import zed.rainxch.githubstore.core.presentation.res.variant_unpinned_toast import java.io.File import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException @@ -371,53 +374,123 @@ 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}", - ) - } - } - } - } + persistPreferredVariantOnPick(action.release) } DetailsAction.ToggleReleaseAssetsPicker -> { _state.update { state -> state.copy(isReleaseSelectorVisible = !state.isReleaseSelectorVisible) } } + + DetailsAction.UnpinPreferredVariant -> { + unpinPreferredVariant() + } + } + } + + /** + * Persists the multi-layer fingerprint of [picked] when: + * - the app is already tracked (otherwise there's no row to update — + * the link flow will derive the fingerprint at install time) + * - the picked asset has a non-null fingerprint (single-asset releases + * and unparseable filenames return null) + * - the new fingerprint differs from what's currently stored, OR the + * stale flag is set (re-picking the same variant after a stale event + * must clear the flag) + * + * Emits a one-time "remembered" toast when the app had no fingerprint + * before this pick — that's the user's first time pinning, and the + * implicit behaviour deserves to be made explicit. + */ + private fun persistPreferredVariantOnPick(picked: GithubAsset) { + val installedApp = _state.value.installedApp ?: return + val installable = _state.value.installableAssets + val fingerprint = + AssetVariant.fingerprintFromPickedAsset( + pickedAssetName = picked.name, + siblingAssetCount = installable.size, + ) ?: return + + val serializedTokens = AssetVariant.serializeTokens(fingerprint.tokens) + val pickedIndex = installable.indexOfFirst { it.id == picked.id }.takeIf { it >= 0 } + + val currentVariant = installedApp.preferredAssetVariant + val currentTokens = installedApp.preferredAssetTokens + val currentGlob = installedApp.assetGlobPattern + val newSiblingCount = installable.size.takeIf { it > 0 } + val sameVariant = + if (fingerprint.variant == null && currentVariant == null) { + true + } else { + fingerprint.variant?.equals(currentVariant, ignoreCase = true) == true + } + val isSameFingerprint = + sameVariant && + serializedTokens == currentTokens && + fingerprint.glob == currentGlob && + pickedIndex == installedApp.pickedAssetIndex && + newSiblingCount == installedApp.pickedAssetSiblingCount + + // Treat the app as "previously unpinned" only when *all* identity + // layers are blank — otherwise we'd nag every time the user + // re-picked the same variant after the resolver populated the + // legacy tail field. + val isFirstPin = + currentVariant.isNullOrBlank() && + currentTokens.isNullOrBlank() && + currentGlob.isNullOrBlank() + + val shouldSave = !isSameFingerprint || installedApp.preferredVariantStale + if (!shouldSave) return + + viewModelScope.launch { + try { + installedAppsRepository.setPreferredVariant( + packageName = installedApp.packageName, + variant = fingerprint.variant, + tokens = serializedTokens, + glob = fingerprint.glob, + pickedIndex = pickedIndex, + siblingCount = newSiblingCount, + ) + if (isFirstPin) { + val label = fingerprint.variant + ?: fingerprint.tokens.firstOrNull() + ?: "" + val message = + if (label.isNotEmpty()) { + getString(Res.string.variant_first_pin_toast, label) + } else { + getString(Res.string.variant_first_pin_toast_generic) + } + _events.send(DetailsEvent.OnMessage(message)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error( + "Failed to persist preferred variant for " + + "${installedApp.packageName}: ${e.message}", + ) + } + } + } + + private fun unpinPreferredVariant() { + val installedApp = _state.value.installedApp ?: return + viewModelScope.launch { + try { + installedAppsRepository.clearPreferredVariant(installedApp.packageName) + _events.send( + DetailsEvent.OnMessage(getString(Res.string.variant_unpinned_toast)), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error( + "Failed to clear preferred variant for " + + "${installedApp.packageName}: ${e.message}", + ) + } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt index 48a37450..bd89694a 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.window.DialogProperties import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubUser +import zed.rainxch.core.domain.util.AssetVariant import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.githubstore.core.presentation.res.Res @@ -68,6 +69,7 @@ fun ReleaseAssetsPicker( modifier: Modifier = Modifier, selectedAsset: GithubAsset? = null, isPickerVisible: Boolean = false, + pinnedVariant: String? = null, ) { val isPickerEnabled by remember(assetsList) { derivedStateOf { assetsList.isNotEmpty() } @@ -77,8 +79,10 @@ fun ReleaseAssetsPicker( showPicker = isPickerVisible, assetsList = assetsList, selectedAsset = selectedAsset, + pinnedVariant = pinnedVariant, onDismiss = { onAction(DetailsAction.ToggleReleaseAssetsPicker) }, onSelect = { onAction(DetailsAction.SelectDownloadAsset(it)) }, + onUnpin = { onAction(DetailsAction.UnpinPreferredVariant) }, ) Column( @@ -128,9 +132,11 @@ fun ReleaseAssetsPicker( private fun ReleaseAssetsItemsPicker( assetsList: List, selectedAsset: GithubAsset?, + pinnedVariant: String?, showPicker: Boolean, onDismiss: () -> Unit, onSelect: (GithubAsset) -> Unit, + onUnpin: () -> Unit, modifier: Modifier = Modifier, ) { if (!showPicker) return @@ -169,6 +175,30 @@ private fun ReleaseAssetsItemsPicker( } } + // "Pinned to: … [Unpin]" hint, only when the user actually + // has a pin. Surfaces both the current pin and a one-tap + // unpin affordance — the only place in the app where a pin + // can be removed without picking a different one. + if (!pinnedVariant.isNullOrBlank()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.variant_picker_pinned, pinnedVariant), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + androidx.compose.material3.TextButton(onClick = onUnpin) { + Text(stringResource(Res.string.variant_picker_unpin)) + } + } + } + HorizontalDivider() LazyColumn( @@ -177,9 +207,14 @@ private fun ReleaseAssetsItemsPicker( ) { if (assetsList.isNotEmpty()) { items(items = assetsList, key = { it.id }) { asset -> + val variantTag = AssetVariant.extract(asset.name) + val isPinned = + !pinnedVariant.isNullOrBlank() && + variantTag?.equals(pinnedVariant, ignoreCase = true) == true ReleaseAssetItem( asset = asset, isSelected = asset.id == selectedAsset?.id, + isPinned = isPinned, onClick = { onSelect(asset) }, modifier = Modifier.fillMaxWidth(), ) @@ -243,6 +278,7 @@ private fun ReleaseAssetItem( isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + isPinned: Boolean = false, ) { Row( modifier = @@ -255,19 +291,36 @@ private fun ReleaseAssetItem( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = asset.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - color = - if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = asset.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + color = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f, fill = false), + ) + if (isPinned) { + Spacer(Modifier.width(6.dp)) + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + ) { + Text( + text = stringResource(Res.string.variant_picker_pinned_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } Text( text = formatFileSize(asset.size), style = MaterialTheme.typography.bodySmall, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt index 7c4b69b6..997cadc6 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt @@ -90,6 +90,7 @@ fun LazyListScope.header( assetsList = state.installableAssets, selectedAsset = state.primaryAsset, isPickerVisible = state.isReleaseSelectorVisible, + pinnedVariant = state.installedApp?.preferredAssetVariant, onAction = onAction, modifier = Modifier.weight(.65f), )