From 9d84077bcd70ac21c858caac0cc65a2e9d79e1e2 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 10 Apr 2026 23:27:12 +0500 Subject: [PATCH 1/6] database: update schema to version 10 to support monorepo asset tracking - Update `AppDatabase` schema to version 10. - Add `assetFilterRegex` (TEXT) and `fallbackToOlderReleases` (INTEGER) columns to the `installed_apps` table. - Implement `MIGRATION_9_10` to handle the database upgrade. - Update `InstalledAppEntity` with new fields to allow filtering assets by regex and falling back to older releases when checking for updates. --- .../10.json | 608 ++++++++++++++++++ .../local/db/migrations/MIGRATION_9_10.kt | 22 + .../local/db/entities/InstalledAppEntity.kt | 13 + 3 files changed, 643 insertions(+) create mode 100644 core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/10.json create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt diff --git a/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/10.json b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/10.json new file mode 100644 index 000000000..27f4f0fd6 --- /dev/null +++ b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/10.json @@ -0,0 +1,608 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "95d6edfa1af67cf198450cdd33fae287", + "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, 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 + } + ], + "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, '95d6edfa1af67cf198450cdd33fae287')" + ] + } +} \ No newline at end of file diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt new file mode 100644 index 000000000..6446d522a --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt @@ -0,0 +1,22 @@ +package zed.rainxch.core.data.local.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Adds per-app monorepo tracking fields to the installed_apps table: + * - assetFilterRegex: optional regex applied to asset (file) names + * - fallbackToOlderReleases: when true, the update checker walks backwards + * through past releases until it finds one whose assets match the filter + * + * Both columns default to nullable / false so existing rows are unaffected. + */ +val MIGRATION_9_10 = + object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE installed_apps ADD COLUMN assetFilterRegex TEXT") + db.execSQL( + "ALTER TABLE installed_apps ADD COLUMN fallbackToOlderReleases INTEGER NOT NULL DEFAULT 0", + ) + } + } 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 a12e5a6c7..68d8969a0 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 @@ -39,4 +39,17 @@ data class InstalledAppEntity( val latestVersionCode: Long? = null, val latestReleasePublishedAt: String? = null, val includePreReleases: Boolean = false, + /** + * Per-app regex applied to asset (file) names. When non-null, only assets + * whose name matches the pattern are considered installable for this app. + * Used to track a single app inside a monorepo that ships multiple apps + * (e.g. `ente-auth.*` against `ente-io/ente`). + */ + val assetFilterRegex: String? = null, + /** + * When true, the update checker walks backward through past releases until + * it finds one whose assets match [assetFilterRegex]. Required for + * monorepos where the latest release is for a *different* app. + */ + val fallbackToOlderReleases: Boolean = false, ) From 9775d8bc147de4cadba6b6ed30491da24806f407 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 10 Apr 2026 23:27:29 +0500 Subject: [PATCH 2/6] core: add AssetFilter utility for per-app asset name regex matching - Create `AssetFilter` class to handle compiled, validated regex patterns for filtering assets. - Implement `parse` method to handle user-supplied patterns with validation and case-insensitivity. - Add `matches` using `containsMatchIn` to support casual substring patterns. - Implement `suggestFromAssetName` to automatically derive filter prefixes by stripping version suffixes from filenames. --- .../rainxch/core/domain/util/AssetFilter.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt new file mode 100644 index 000000000..d03445c69 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt @@ -0,0 +1,70 @@ +package zed.rainxch.core.domain.util + +/** + * Compiled, validated wrapper around a per-app asset name regex. + * + * Use [AssetFilter.parse] when reading a (possibly user-supplied) pattern out + * of storage or a form field — it returns `null` for blank input and a + * [Result.failure] for an invalid regex, so the caller can decide whether to + * surface a validation error. + * + * Once compiled, [matches] is allocation-free for the hot path used by + * `checkForUpdates` (compile once per app, evaluate against many asset names). + * + * Matching uses [Regex.containsMatchIn], not [Regex.matches]. That makes + * casual patterns like `ente-auth` or `arm64` "just work" without forcing the + * user to wrap the value in `.*` — it matches Obtainium's behaviour. + */ +class AssetFilter private constructor( + val pattern: String, + private val regex: Regex, +) { + fun matches(assetName: String): Boolean = regex.containsMatchIn(assetName) + + override fun equals(other: Any?): Boolean = other is AssetFilter && other.pattern == pattern + + override fun hashCode(): Int = pattern.hashCode() + + override fun toString(): String = "AssetFilter($pattern)" + + companion object { + /** + * Parses a raw user-supplied pattern. + * + * @return `null` if [raw] is null/blank, otherwise a [Result] wrapping + * either the compiled filter or the [PatternSyntaxException]-equivalent + * exception thrown by Kotlin's regex compiler. + */ + fun parse(raw: String?): Result? { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return null + return runCatching { + AssetFilter(pattern = trimmed, regex = Regex(trimmed, RegexOption.IGNORE_CASE)) + } + } + + /** + * Suggests a sensible filter from a sample asset name. Strips the + * version suffix (anything from the first `-` onward) and + * returns the leading prefix as a literal-prefix anchor. + * + * Examples: + * ente-auth-3.2.5-arm64-v8a.apk → ente-auth- + * Photos-1.7.0-universal.apk → Photos- + * app_2024-01-15.apk → app_ + * no-version.apk → null (cannot derive a useful prefix) + * + * Returns `null` when the asset name has no clear version anchor — + * blindly returning the full filename would create a filter that + * matches only that exact build. + */ + fun suggestFromAssetName(assetName: String): String? { + // Try the common "name-1.2.3" / "name_1.2.3" / "name 1.2.3" patterns. + val versionAnchor = Regex("[-_ .]\\d") + val match = versionAnchor.find(assetName) ?: return null + val prefix = assetName.substring(0, match.range.first + 1) + // Need at least 2 meaningful chars; otherwise the suggestion is noise. + return prefix.takeIf { it.length >= 2 } + } + } +} From bf9bacefb8a37a2f5f8563c849807446daf41f2d Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 10 Apr 2026 23:28:11 +0500 Subject: [PATCH 3/6] core: add asset filtering and monorepo support for app updates - Implement `assetFilterRegex` and `fallbackToOlderReleases` in `InstalledApp` to support tracking specific apps within monorepos. - Update `InstalledAppsRepository` to fetch a window of 50 releases (up from 1) to allow falling back to older releases when the latest one does not match the asset filter. - Add `resolveTrackedRelease` logic to match assets against regex filters and architecture requirements across the release window. - Introduce `previewMatchingAssets` to provide a dry-run helper for testing asset filters in the UI. - Update `InstalledAppDao` and database schema (version 10) to persist new filtering fields. - Update `AppsRepository` to support linking apps with initial asset filter configurations. - Add Room migration (MIGRATION_9_10) for the new database columns. --- .../core/data/local/db/initDatabase.kt | 2 + .../rainxch/core/data/local/db/AppDatabase.kt | 2 +- .../core/data/local/db/dao/InstalledAppDao.kt | 14 + .../core/data/mappers/InstalledAppsMappers.kt | 4 + .../repository/InstalledAppsRepositoryImpl.kt | 264 ++++++++++++++---- .../rainxch/core/domain/model/InstalledApp.kt | 13 + .../repository/InstalledAppsRepository.kt | 45 +++ .../data/repository/AppsRepositoryImpl.kt | 5 + .../apps/domain/repository/AppsRepository.kt | 7 +- .../mappers/InstalledAppMapper.kt | 4 + 10 files changed, 304 insertions(+), 56 deletions(-) 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 d6bda8652..576d9a0cf 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 @@ -11,6 +11,7 @@ import zed.rainxch.core.data.local.db.migrations.MIGRATION_5_6 import zed.rainxch.core.data.local.db.migrations.MIGRATION_6_7 import zed.rainxch.core.data.local.db.migrations.MIGRATION_7_8 import zed.rainxch.core.data.local.db.migrations.MIGRATION_8_9 +import zed.rainxch.core.data.local.db.migrations.MIGRATION_9_10 fun initDatabase(context: Context): AppDatabase { val appContext = context.applicationContext @@ -29,5 +30,6 @@ fun initDatabase(context: Context): AppDatabase { MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, ).build() } 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 8376fd31c..81d1a4710 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 = 9, + version = 10, 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 653031eea..4a93ad7d3 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 @@ -74,6 +74,20 @@ interface InstalledAppDao { @Query("UPDATE installed_apps SET includePreReleases = :enabled WHERE packageName = :packageName") suspend fun updateIncludePreReleases(packageName: String, enabled: Boolean) + @Query( + """ + UPDATE installed_apps + SET assetFilterRegex = :regex, + fallbackToOlderReleases = :fallback + WHERE packageName = :packageName + """, + ) + suspend fun updateAssetFilter( + packageName: String, + regex: String?, + fallback: Boolean, + ) + @Query("UPDATE installed_apps SET lastCheckedAt = :timestamp WHERE packageName = :packageName") suspend fun updateLastChecked( packageName: String, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt index 88c234f04..818cb903b 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 @@ -38,6 +38,8 @@ fun InstalledApp.toEntity(): InstalledAppEntity = latestReleasePublishedAt = latestReleasePublishedAt, signingFingerprint = signingFingerprint, includePreReleases = includePreReleases, + assetFilterRegex = assetFilterRegex, + fallbackToOlderReleases = fallbackToOlderReleases, ) fun InstalledAppEntity.toDomain(): InstalledApp = @@ -75,4 +77,6 @@ fun InstalledAppEntity.toDomain(): InstalledApp = latestReleasePublishedAt = latestReleasePublishedAt, signingFingerprint = signingFingerprint, includePreReleases = includePreReleases, + assetFilterRegex = assetFilterRegex, + fallbackToOlderReleases = fallbackToOlderReleases, ) 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 6da9595f1..8ed594bba 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 @@ -19,11 +19,14 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toEntity import zed.rainxch.core.data.network.executeRequest +import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.repository.MatchingPreview import zed.rainxch.core.domain.system.Installer +import zed.rainxch.core.domain.util.AssetFilter class InstalledAppsRepositoryImpl( private val database: AppDatabase, @@ -32,6 +35,22 @@ class InstalledAppsRepositoryImpl( private val installer: Installer, private val httpClient: HttpClient, ) : InstalledAppsRepository { + private companion object { + /** + * How many releases the update checker fetches in one request. + * Picked to balance: + * - Monorepos that ship multiple sibling apps in close succession + * (need a few releases of headroom to find a match for the + * targeted app via [InstalledApp.fallbackToOlderReleases]) + * - Avoiding unnecessary GitHub API quota burn for the common case + * of a single-app repo where 1 release is enough. + * + * 50 is the GitHub API per_page maximum that doesn't require + * pagination, and is enough to cover ~3 months of daily releases. + */ + const val RELEASE_WINDOW = 50 + } + override suspend fun executeInTransaction(block: suspend () -> R): R = database.useWriterConnection { transactor -> transactor.immediateTransaction { @@ -71,91 +90,169 @@ class InstalledAppsRepositoryImpl( installedAppsDao.deleteByPackageName(packageName) } - private suspend fun fetchLatestPublishedRelease( + /** + * Fetches up to [RELEASE_WINDOW] releases for [owner]/[repo], filters + * out drafts, applies the pre-release flag, and returns them sorted by + * `publishedAt` descending. Empty list on failure (logged at error). + */ + private suspend fun fetchReleaseWindow( owner: String, repo: String, includePreReleases: Boolean, - ): GithubRelease? { + ): List { return try { val releases = httpClient .executeRequest> { get("/repos/$owner/$repo/releases") { header(HttpHeaders.Accept, "application/vnd.github+json") - parameter("per_page", 10) + parameter("per_page", RELEASE_WINDOW) } - }.getOrNull() ?: return null + }.getOrNull() ?: return emptyList() + + releases + .asSequence() + .filter { it.draft != true } + .filter { includePreReleases || it.prerelease != true } + .sortedByDescending { it.publishedAt ?: it.createdAt ?: "" } + .map { it.toDomain() } + .toList() + } catch (e: Exception) { + Logger.e { "Failed to fetch releases for $owner/$repo: ${e.message}" } + emptyList() + } + } - val latest = + /** + * Result of [resolveTrackedRelease] — a candidate release plus the asset + * the installer should download for it. `null` when no release in the + * window contains a usable asset (after filter + arch matching). + */ + private data class ResolvedRelease( + val release: GithubRelease, + val primaryAsset: GithubAsset, + ) + + /** + * Walks [releases] (already in newest-first order) and returns the first + * release whose installable asset list — after applying [filter] — yields + * a primary asset for the current architecture. When [filter] is null, + * only the first release in the window is considered: this preserves the + * pre-existing behaviour for apps that don't track a monorepo. + * + * When [filter] is non-null and [fallbackToOlderReleases] is false, the + * walker still only inspects the first release. The semantics are: + * "Apply the filter to the latest release, but don't dig further." + * This matches Obtainium's defaults and avoids accidental downgrades for + * apps where the user just wants a stricter asset picker. + */ + private fun resolveTrackedRelease( + releases: List, + filter: AssetFilter?, + fallbackToOlderReleases: Boolean, + ): ResolvedRelease? { + if (releases.isEmpty()) return null + + val candidates = + if (filter != null && fallbackToOlderReleases) { releases - .asSequence() - .filter { it.draft != true } - .filter { includePreReleases || it.prerelease != true } - .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } - ?: return null + } else { + releases.take(1) + } - latest.toDomain() - } catch (e: Exception) { - Logger.e { "Failed to fetch latest release for $owner/$repo: ${e.message}" } - null + for (release in candidates) { + val installableForPlatform = + release.assets.filter { installer.isAssetInstallable(it.name) } + val installableForApp = + if (filter == null) installableForPlatform + else installableForPlatform.filter { filter.matches(it.name) } + + if (installableForApp.isEmpty()) continue + val primary = installer.choosePrimaryAsset(installableForApp) ?: continue + return ResolvedRelease(release, primary) } + + return null } override suspend fun checkForUpdates(packageName: String): Boolean { val app = installedAppsDao.getAppByPackage(packageName) ?: return false try { - val latestRelease = - fetchLatestPublishedRelease( + val releases = + fetchReleaseWindow( owner = app.repoOwner, repo = app.repoName, includePreReleases = app.includePreReleases, ) - if (latestRelease != null) { - val normalizedInstalledTag = normalizeVersion(app.installedVersion) - val normalizedLatestTag = normalizeVersion(latestRelease.tagName) - - val installableAssets = - latestRelease.assets.filter { asset -> - installer.isAssetInstallable(asset.name) - } - val primaryAsset = installer.choosePrimaryAsset(installableAssets) - - // Only flag as update if the latest version is actually newer - // (not just different — avoids false "downgrade" notifications) - val isUpdateAvailable = - if (normalizedInstalledTag == normalizedLatestTag) { - false - } else { - isVersionNewer(normalizedLatestTag, normalizedInstalledTag) - } + if (releases.isEmpty()) { + installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) + return false + } - Logger.d { - "Update check for ${app.appName}: " + - "installedTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " + - "installedCode=${app.installedVersionCode}, " + - "isUpdate=$isUpdateAvailable" - } + // Compile the per-app filter once. Invalid regexes are treated as + // "no filter" so we don't break the app silently — the user is + // told about the syntax error in the advanced settings sheet. + val compiledFilter = + AssetFilter.parse(app.assetFilterRegex) + ?.onFailure { error -> + Logger.w { + "Invalid asset filter for $packageName " + + "(${app.assetFilterRegex}): ${error.message} — ignoring" + } + }?.getOrNull() - installedAppsDao.updateVersionInfo( - packageName = packageName, - available = isUpdateAvailable, - version = latestRelease.tagName, - assetName = primaryAsset?.name, - assetUrl = primaryAsset?.downloadUrl, - assetSize = primaryAsset?.size, - releaseNotes = latestRelease.description ?: "", - timestamp = System.currentTimeMillis(), - latestVersionName = latestRelease.tagName, - latestVersionCode = null, - latestReleasePublishedAt = latestRelease.publishedAt, + val resolved = + resolveTrackedRelease( + releases = releases, + filter = compiledFilter, + fallbackToOlderReleases = app.fallbackToOlderReleases, ) - return isUpdateAvailable - } else { + if (resolved == null) { + Logger.d { + "No matching release found for ${app.appName} in window of ${releases.size}; " + + "filter=${app.assetFilterRegex}, fallback=${app.fallbackToOlderReleases}" + } installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) + return false + } + + val (matchedRelease, primaryAsset) = resolved + val normalizedInstalledTag = normalizeVersion(app.installedVersion) + val normalizedLatestTag = normalizeVersion(matchedRelease.tagName) + + val isUpdateAvailable = + if (normalizedInstalledTag == normalizedLatestTag) { + false + } else { + isVersionNewer(normalizedLatestTag, normalizedInstalledTag) + } + + Logger.d { + "Update check for ${app.appName}: " + + "installedTag=${app.installedVersion}, " + + "matchedTag=${matchedRelease.tagName}, " + + "matchedAsset=${primaryAsset.name}, " + + "isUpdate=$isUpdateAvailable" } + + installedAppsDao.updateVersionInfo( + packageName = packageName, + available = isUpdateAvailable, + version = matchedRelease.tagName, + assetName = primaryAsset.name, + assetUrl = primaryAsset.downloadUrl, + assetSize = primaryAsset.size, + releaseNotes = matchedRelease.description ?: "", + timestamp = System.currentTimeMillis(), + latestVersionName = matchedRelease.tagName, + latestVersionCode = null, + latestReleasePublishedAt = matchedRelease.publishedAt, + ) + + return isUpdateAvailable } catch (e: Exception) { Logger.e { "Failed to check updates for $packageName: ${e.message}" } installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) @@ -247,6 +344,65 @@ class InstalledAppsRepositoryImpl( installedAppsDao.updateIncludePreReleases(packageName, enabled) } + override suspend fun setAssetFilter( + packageName: String, + regex: String?, + fallbackToOlderReleases: Boolean, + ) { + val normalized = regex?.trim()?.takeIf { it.isNotEmpty() } + installedAppsDao.updateAssetFilter( + packageName = packageName, + regex = normalized, + fallback = fallbackToOlderReleases, + ) + } + + override suspend fun previewMatchingAssets( + owner: String, + repo: String, + regex: String?, + includePreReleases: Boolean, + fallbackToOlderReleases: Boolean, + ): MatchingPreview { + val parseResult = AssetFilter.parse(regex) + if (parseResult != null && parseResult.isFailure) { + return MatchingPreview( + release = null, + matchedAssets = emptyList(), + regexError = parseResult.exceptionOrNull()?.message, + ) + } + val filter = parseResult?.getOrNull() + + val releases = fetchReleaseWindow(owner, repo, includePreReleases) + if (releases.isEmpty()) { + return MatchingPreview(release = null, matchedAssets = emptyList()) + } + + val candidates = + if (filter != null && fallbackToOlderReleases) { + releases + } else { + releases.take(1) + } + + for (release in candidates) { + val installableForPlatform = + release.assets.filter { installer.isAssetInstallable(it.name) } + val matched = + if (filter == null) installableForPlatform + else installableForPlatform.filter { filter.matches(it.name) } + if (matched.isNotEmpty()) { + return MatchingPreview(release = release, matchedAssets = matched) + } + } + + return MatchingPreview( + release = releases.firstOrNull(), + matchedAssets = emptyList(), + ) + } + private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim() /** 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 2f1d65cd5..673c03dc7 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 @@ -34,4 +34,17 @@ data class InstalledApp( val latestVersionCode: Long? = null, val latestReleasePublishedAt: String? = null, val includePreReleases: Boolean = false, + /** + * Optional regex applied to asset names. When set, only assets whose + * names match the pattern are considered installable for this app — + * the building block for tracking one app inside a monorepo that ships + * multiple apps (e.g. `ente-auth.*` against `ente-io/ente`). + */ + val assetFilterRegex: String? = null, + /** + * When true, the update check walks back through past releases looking + * for one whose assets match [assetFilterRegex]. Required for monorepos + * where the latest release belongs to a sibling app. + */ + val fallbackToOlderReleases: Boolean = false, ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt index 85b9ebff6..483fb8cfb 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 @@ -1,6 +1,8 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow +import zed.rainxch.core.domain.model.GithubAsset +import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstalledApp interface InstalledAppsRepository { @@ -49,5 +51,48 @@ interface InstalledAppsRepository { enabled: Boolean, ) + /** + * Persists per-app monorepo settings: an optional regex applied to asset + * names and whether the update checker should fall back to older + * releases when the latest one has no matching asset. + * + * Implementations should re-check the app for updates immediately so + * the UI reflects the new state without a manual refresh. + */ + suspend fun setAssetFilter( + packageName: String, + regex: String?, + fallbackToOlderReleases: Boolean, + ) + + /** + * Dry-run helper for the per-app advanced settings sheet. Fetches a + * window of releases for [owner]/[repo] (honouring [includePreReleases]) + * and returns the assets in the most-recent release that match + * [regex] — or, if [fallbackToOlderReleases] is true and the latest + * release matches nothing, the assets from the next release that does. + * + * Returns an empty list when no matching release is found in the + * window. Never throws — failures resolve to an empty list and are + * logged at debug level. + */ + suspend fun previewMatchingAssets( + owner: String, + repo: String, + regex: String?, + includePreReleases: Boolean, + fallbackToOlderReleases: Boolean, + ): MatchingPreview + suspend fun executeInTransaction(block: suspend () -> R): R } + +/** + * Snapshot returned by [InstalledAppsRepository.previewMatchingAssets] for + * the per-app advanced settings sheet's live preview. + */ +data class MatchingPreview( + val release: GithubRelease?, + val matchedAssets: List, + val regexError: String? = null, +) 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 f5dca9a58..856b7b59f 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 @@ -151,9 +151,12 @@ class AppsRepositoryImpl( override suspend fun linkAppToRepo( deviceApp: DeviceApp, repoInfo: GithubRepoInfo, + assetFilterRegex: String?, + fallbackToOlderReleases: Boolean, ) { val now = Clock.System.now().toEpochMilliseconds() val globalPreRelease = tweaksRepository.getIncludePreReleases().first() + val normalizedFilter = assetFilterRegex?.trim()?.takeIf { it.isNotEmpty() } val installedApp = InstalledApp( @@ -187,6 +190,8 @@ class AppsRepositoryImpl( installedVersionCode = deviceApp.versionCode, signingFingerprint = deviceApp.signingFingerprint, includePreReleases = globalPreRelease, + assetFilterRegex = normalizedFilter, + fallbackToOlderReleases = fallbackToOlderReleases, ) appsRepository.saveInstalledApp(installedApp) 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 609a6200a..df0a55405 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 @@ -27,7 +27,12 @@ interface AppsRepository { suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo? - suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo) + suspend fun linkAppToRepo( + deviceApp: DeviceApp, + repoInfo: GithubRepoInfo, + assetFilterRegex: String? = null, + fallbackToOlderReleases: Boolean = false, + ) suspend fun exportApps(): String 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 7006b60fa..138e5163c 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 @@ -38,6 +38,8 @@ fun InstalledApp.toUi(): InstalledAppUi = appName = appName, signingFingerprint = signingFingerprint, includePreReleases = includePreReleases, + assetFilterRegex = assetFilterRegex, + fallbackToOlderReleases = fallbackToOlderReleases, ) fun InstalledAppUi.toDomain(): InstalledApp = @@ -75,4 +77,6 @@ fun InstalledAppUi.toDomain(): InstalledApp = appName = appName, signingFingerprint = signingFingerprint, includePreReleases = includePreReleases, + assetFilterRegex = assetFilterRegex, + fallbackToOlderReleases = fallbackToOlderReleases, ) From 440643b45fa6a764a161bf3233a4b60d853fc01f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 10 Apr 2026 23:28:28 +0500 Subject: [PATCH 4/6] feat: add advanced app settings for monorepo support - Introduce `AdvancedAppSettingsBottomSheet` to configure per-app asset filters (regex) and "fallback to older releases" logic. - Add live preview functionality in the advanced settings sheet to visualize asset matching before saving. - Enhance `LinkAppBottomSheet` with an asset filter and fallback toggle during the repository linking process. - Implement automatic filter suggestions during linking based on package names and app titles. - Update `AppsState`, `AppsAction`, and `AppsViewModel` to manage advanced filter state, validation, and debounced preview refreshes. - Add UI indicators to the main app list to highlight apps with active custom filters. - Include new localized strings for filter labels, help text, and validation errors. --- .../composeResources/values/strings.xml | 20 + .../rainxch/apps/presentation/AppsAction.kt | 15 + .../zed/rainxch/apps/presentation/AppsRoot.kt | 63 ++- .../rainxch/apps/presentation/AppsState.kt | 34 ++ .../apps/presentation/AppsViewModel.kt | 322 ++++++++++++++- .../AdvancedAppSettingsBottomSheet.kt | 365 ++++++++++++++++++ .../components/LinkAppBottomSheet.kt | 116 +++++- .../apps/presentation/model/InstalledAppUi.kt | 2 + 8 files changed, 923 insertions(+), 14 deletions(-) create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 9ad81f840..ee8dc9d50 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -638,4 +638,24 @@ Pre-releases + + + Asset filter + e.g. ente-auth + Only assets matching this pattern (regex) will be installed. Useful for monorepos that ship multiple apps. + Invalid regex + No assets match this filter + Showing %1$d of %2$d assets + Fall back to older releases + Walk back through past releases until one matches the filter. Required for monorepos where the latest release belongs to a sibling app. + Advanced filter + Configure how this app is matched in the repository. Use these settings when the repo ships multiple apps. + Open advanced filter + Save + Preview + Refresh preview + Type a filter to preview matching assets. + No releases in the recent window contain matching assets. Try enabling fallback to older releases or adjusting the regex. + Could not load preview. Check your connection and try again. + Matched in %1$s · %2$d asset(s) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 8923c0d00..31594c490 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt @@ -60,9 +60,24 @@ sealed interface AppsAction { data class OnLinkAssetSelected(val asset: GithubAssetUi) : AppsAction data object OnBackToEnterUrl : AppsAction + /** Asset filter input on the link-sheet PickAsset step. */ + data class OnLinkAssetFilterChanged(val filter: String) : AppsAction + + /** Toggle for "fall back to older releases" on the link-sheet PickAsset step. */ + data class OnLinkFallbackToggled(val enabled: Boolean) : AppsAction + // Per-app pre-release toggle data class OnTogglePreReleases(val packageName: String, val enabled: Boolean) : AppsAction + // Per-app advanced settings sheet (monorepo) + data class OnOpenAdvancedSettings(val app: InstalledAppUi) : AppsAction + data object OnDismissAdvancedSettings : AppsAction + data class OnAdvancedFilterChanged(val filter: String) : AppsAction + data class OnAdvancedFallbackToggled(val enabled: Boolean) : AppsAction + data object OnAdvancedSaveFilter : AppsAction + data object OnAdvancedClearFilter : AppsAction + data object OnAdvancedRefreshPreview : AppsAction + // Export/Import data object OnExportApps : AppsAction data object OnImportApps : AppsAction diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index 5d603d7ad..9981fcab7 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 @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Add 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.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Update @@ -84,6 +85,7 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.apps.presentation.components.AdvancedAppSettingsBottomSheet import zed.rainxch.apps.presentation.components.InstalledAppIcon import zed.rainxch.apps.presentation.components.LinkAppBottomSheet import zed.rainxch.apps.presentation.model.AppItem @@ -99,6 +101,7 @@ import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.add_by_link +import zed.rainxch.githubstore.core.presentation.res.advanced_settings_open import zed.rainxch.githubstore.core.presentation.res.cancel import zed.rainxch.githubstore.core.presentation.res.check_for_updates import zed.rainxch.githubstore.core.presentation.res.checking @@ -326,6 +329,14 @@ fun AppsScreen( ) } + // Per-app advanced settings (monorepo filter / fallback) + if (state.advancedSettingsApp != null) { + AdvancedAppSettingsBottomSheet( + state = state, + onAction = onAction, + ) + } + // Uninstall confirmation dialog state.appPendingUninstall?.let { app -> AlertDialog( @@ -506,6 +517,9 @@ fun AppsScreen( onTogglePreReleases = { enabled -> onAction(AppsAction.OnTogglePreReleases(appItem.installedApp.packageName, enabled)) }, + onAdvancedSettingsClick = { + onAction(AppsAction.OnOpenAdvancedSettings(appItem.installedApp)) + }, modifier = Modifier .then( @@ -597,6 +611,7 @@ fun AppItemCard( onUninstallClick: () -> Unit, onRepoClick: () -> Unit, onTogglePreReleases: (Boolean) -> Unit, + onAdvancedSettingsClick: () -> Unit, modifier: Modifier = Modifier, ) { val app = appItem.installedApp @@ -728,15 +743,47 @@ fun AppItemCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Checkbox( - checked = app.includePreReleases, - onCheckedChange = onTogglePreReleases, - enabled = !isBusy, - modifier = - Modifier.semantics { - contentDescription = preReleaseString + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + // Subtle visual cue when a monorepo filter is active — + // the icon tints to primary, so users can tell at a + // glance which apps have an active filter without + // having to open the sheet. + val advancedFilterDescription = + stringResource(Res.string.advanced_settings_open) + val hasFilter = + !app.assetFilterRegex.isNullOrBlank() || app.fallbackToOlderReleases + IconButton( + onClick = onAdvancedSettingsClick, + enabled = !isBusy, + modifier = Modifier.semantics { + contentDescription = advancedFilterDescription }, - ) + ) { + Icon( + imageVector = Icons.Default.FilterAlt, + contentDescription = null, + tint = + if (hasFilter) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + Checkbox( + checked = app.includePreReleases, + onCheckedChange = onTogglePreReleases, + enabled = !isBusy, + modifier = + Modifier.semantics { + contentDescription = preReleaseString + }, + ) + } } Spacer(Modifier.height(12.dp)) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt index 69a70cebf..b7425eeb0 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt @@ -41,6 +41,22 @@ data class AppsState( val linkSelectedAsset: GithubAssetUi? = null, val linkDownloadProgress: Int? = null, val fetchedRepoInfo: GithubRepoInfoUi? = null, + /** Filter input on the PickAsset step. Live-narrows [linkInstallableAssets]. */ + val linkAssetFilter: String = "", + /** Validation message for [linkAssetFilter] (invalid regex syntax). */ + val linkAssetFilterError: String? = null, + /** Whether linking should also enable fallback-to-older-releases. */ + val linkFallbackToOlder: Boolean = false, + // Per-app advanced settings (monorepo support) + val advancedSettingsApp: InstalledAppUi? = null, + val advancedFilterDraft: String = "", + val advancedFallbackDraft: Boolean = false, + val advancedFilterError: String? = null, + val advancedPreviewLoading: Boolean = false, + val advancedPreviewMatched: ImmutableList = persistentListOf(), + val advancedPreviewTag: String? = null, + val advancedPreviewMessage: String? = null, + val advancedSavingFilter: Boolean = false, // Export/Import val isExporting: Boolean = false, val isImporting: Boolean = false, @@ -58,6 +74,24 @@ data class AppsState( it.packageName.contains(deviceAppSearchQuery, ignoreCase = true) }.toImmutableList() } + + /** + * Live-filtered view of [linkInstallableAssets] for the link sheet's + * PickAsset step. When the filter is invalid we keep showing the full + * list so the user can still pick something — the error is surfaced via + * [linkAssetFilterError]. + */ + val filteredLinkAssets: ImmutableList + get() { + val raw = linkAssetFilter.trim() + if (raw.isEmpty()) return linkInstallableAssets + val regex = + runCatching { Regex(raw, RegexOption.IGNORE_CASE) }.getOrNull() + ?: return linkInstallableAssets + return linkInstallableAssets + .filter { regex.containsMatchIn(it.name) } + .toImmutableList() + } } enum class LinkStep { 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 cb6b167a1..1a00a88e8 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 @@ -35,6 +35,7 @@ import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase +import zed.rainxch.core.domain.util.AssetFilter import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.core.presentation.res.* import java.io.File @@ -58,6 +59,9 @@ class AppsViewModel( private var updateAllJob: Job? = null private var lastAutoCheckTimestamp: Long = 0L + /** Debounced re-runs of the live preview in the advanced settings sheet. */ + private var advancedPreviewJob: Job? = null + private val _state = MutableStateFlow(AppsState()) val state = _state @@ -307,14 +311,74 @@ class AppsViewModel( linkDownloadProgress = null, linkValidationStatus = null, repoValidationError = null, + linkAssetFilter = "", + linkAssetFilterError = null, + linkFallbackToOlder = false, ) } } + is AppsAction.OnLinkAssetFilterChanged -> { + onLinkAssetFilterChanged(action.filter) + } + + is AppsAction.OnLinkFallbackToggled -> { + _state.update { it.copy(linkFallbackToOlder = action.enabled) } + } + is AppsAction.OnTogglePreReleases -> { togglePreReleases(action.packageName, action.enabled) } + is AppsAction.OnOpenAdvancedSettings -> { + openAdvancedSettings(action.app) + } + + AppsAction.OnDismissAdvancedSettings -> { + _state.update { + it.copy( + advancedSettingsApp = null, + advancedFilterDraft = "", + advancedFallbackDraft = false, + advancedFilterError = null, + advancedPreviewLoading = false, + advancedPreviewMatched = persistentListOf(), + advancedPreviewTag = null, + advancedPreviewMessage = null, + advancedSavingFilter = false, + ) + } + advancedPreviewJob?.cancel() + advancedPreviewJob = null + } + + is AppsAction.OnAdvancedFilterChanged -> { + onAdvancedFilterChanged(action.filter) + } + + is AppsAction.OnAdvancedFallbackToggled -> { + _state.update { it.copy(advancedFallbackDraft = action.enabled) } + schedulePreviewRefresh() + } + + AppsAction.OnAdvancedSaveFilter -> { + saveAdvancedSettings() + } + + AppsAction.OnAdvancedClearFilter -> { + _state.update { + it.copy( + advancedFilterDraft = "", + advancedFilterError = null, + ) + } + schedulePreviewRefresh() + } + + AppsAction.OnAdvancedRefreshPreview -> { + refreshAdvancedPreview() + } + AppsAction.OnExportApps -> { exportApps() } @@ -390,6 +454,167 @@ class AppsViewModel( } } + private fun openAdvancedSettings(app: InstalledAppUi) { + _state.update { + it.copy( + advancedSettingsApp = app, + advancedFilterDraft = app.assetFilterRegex.orEmpty(), + advancedFallbackDraft = app.fallbackToOlderReleases, + advancedFilterError = null, + advancedPreviewLoading = true, + advancedPreviewMatched = persistentListOf(), + advancedPreviewTag = null, + advancedPreviewMessage = null, + advancedSavingFilter = false, + ) + } + refreshAdvancedPreview() + } + + private fun onAdvancedFilterChanged(value: String) { + val parseResult = AssetFilter.parse(value) + val errorKey = parseResult?.exceptionOrNull()?.let { "invalid" } + _state.update { + it.copy( + advancedFilterDraft = value, + advancedFilterError = errorKey, + ) + } + if (errorKey == null) schedulePreviewRefresh() + } + + /** + * Debounces preview refresh while the user is typing. We don't want to + * issue a fresh GitHub releases call on every keystroke — 350ms after + * input stops is plenty responsive without burning rate limit. + */ + private fun schedulePreviewRefresh() { + advancedPreviewJob?.cancel() + advancedPreviewJob = + viewModelScope.launch { + delay(350) + refreshAdvancedPreview() + } + } + + private fun refreshAdvancedPreview() { + val app = _state.value.advancedSettingsApp ?: return + val draftFilter = _state.value.advancedFilterDraft + val draftFallback = _state.value.advancedFallbackDraft + + // Validate locally before hitting the network — invalid regex + // shows the error inline and aborts the preview. + val parseResult = AssetFilter.parse(draftFilter) + if (parseResult != null && parseResult.isFailure) { + _state.update { + it.copy( + advancedPreviewLoading = false, + advancedPreviewMatched = persistentListOf(), + advancedPreviewTag = null, + advancedPreviewMessage = null, + advancedFilterError = "invalid", + ) + } + return + } + + advancedPreviewJob?.cancel() + advancedPreviewJob = + viewModelScope.launch { + _state.update { it.copy(advancedPreviewLoading = true) } + try { + val preview = + installedAppsRepository.previewMatchingAssets( + owner = app.repoOwner, + repo = app.repoName, + regex = draftFilter.takeIf { it.isNotBlank() }, + includePreReleases = app.includePreReleases, + fallbackToOlderReleases = draftFallback, + ) + _state.update { + it.copy( + advancedPreviewLoading = false, + advancedPreviewMatched = + preview.matchedAssets + .map { asset -> asset.toUi() } + .toImmutableList(), + advancedPreviewTag = preview.release?.tagName, + advancedPreviewMessage = + if (preview.matchedAssets.isEmpty() && preview.regexError == null) { + "no_match" + } else { + null + }, + advancedFilterError = + if (preview.regexError != null) "invalid" else null, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Failed to preview matching assets: ${e.message}") + _state.update { + it.copy( + advancedPreviewLoading = false, + advancedPreviewMatched = persistentListOf(), + advancedPreviewTag = null, + advancedPreviewMessage = "preview_failed", + ) + } + } + } + } + + private fun saveAdvancedSettings() { + val app = _state.value.advancedSettingsApp ?: return + val draftFilter = _state.value.advancedFilterDraft.trim() + val draftFallback = _state.value.advancedFallbackDraft + + // Final regex validation — if it's broken we refuse to save. + val parseResult = AssetFilter.parse(draftFilter) + if (parseResult != null && parseResult.isFailure) { + _state.update { it.copy(advancedFilterError = "invalid") } + return + } + + viewModelScope.launch { + _state.update { it.copy(advancedSavingFilter = true) } + try { + installedAppsRepository.setAssetFilter( + packageName = app.packageName, + regex = draftFilter.takeIf { it.isNotEmpty() }, + fallbackToOlderReleases = draftFallback, + ) + // Re-run the update check immediately so the UI badge updates + // without waiting for the next periodic worker run. + installedAppsRepository.checkForUpdates(app.packageName) + _state.update { + it.copy( + advancedSettingsApp = null, + advancedFilterDraft = "", + advancedFallbackDraft = false, + advancedFilterError = null, + advancedPreviewLoading = false, + advancedPreviewMatched = persistentListOf(), + advancedPreviewTag = null, + advancedPreviewMessage = null, + advancedSavingFilter = false, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Failed to save advanced settings: ${e.message}") + _state.update { + it.copy( + advancedSavingFilter = false, + advancedPreviewMessage = "save_failed", + ) + } + } + } + } + private fun uninstallApp(app: InstalledAppUi) { viewModelScope.launch { try { @@ -838,10 +1063,78 @@ class AppsViewModel( linkDownloadProgress = null, fetchedRepoInfo = null, isValidatingRepo = false, + linkAssetFilter = "", + linkAssetFilterError = null, + linkFallbackToOlder = false, + ) + } + } + + private fun onLinkAssetFilterChanged(value: String) { + // Validate the regex on every keystroke so the user gets immediate + // feedback. The state's filteredLinkAssets getter falls back to the + // unfiltered list when the regex is invalid, so the picker stays + // usable even mid-typing. + val parseResult = AssetFilter.parse(value) + val error = + parseResult?.exceptionOrNull()?.let { _ -> + // Localized message comes from the UI layer; here we just + // signal that something is wrong. + "invalid" + } + _state.update { + it.copy( + linkAssetFilter = value, + linkAssetFilterError = error, ) } } + /** + * Picks a sensible default for the link-flow filter. Tries, in order: + * 1. The trailing segment of the package name (e.g. `io.ente.auth` → `auth`) + * 2. A token derived from the device app's display name (e.g. + * `Ente Auth` → `auth`) + * 3. [AssetFilter.suggestFromAssetName] on the first asset + * + * Returns the first non-blank candidate that actually matches at least + * one of the available assets — otherwise null, which leaves the field + * empty so we don't pre-fill something useless. + */ + private fun suggestFilterForLink( + deviceAppName: String, + packageName: String, + firstAssetName: String?, + ): String? { + val state = _state.value + val assets = state.linkInstallableAssets + + fun candidateMatches(candidate: String): Boolean { + val regex = + runCatching { Regex(candidate, RegexOption.IGNORE_CASE) }.getOrNull() + ?: return false + return assets.any { regex.containsMatchIn(it.name) } + } + + // 1. Last package segment (commonly the most distinctive token). + val packageTail = packageName.substringAfterLast('.').lowercase() + if (packageTail.length >= 3 && candidateMatches(packageTail)) { + return packageTail + } + + // 2. Significant words from the display name. + deviceAppName + .split(' ', '-', '_') + .map { it.lowercase().trim() } + .filter { it.length >= 3 } + .forEach { token -> + if (candidateMatches(token)) return token + } + + // 3. Heuristic on the first asset name. + return firstAssetName?.let { AssetFilter.suggestFromAssetName(it) } + } + private fun validateAndLinkRepo() { val selectedApp = _state.value.selectedDeviceApp ?: return val url = _state.value.repoUrl.trim() @@ -947,12 +1240,27 @@ class AppsViewModel( return@launch } + // Seed an auto-suggestion based on the device app's package + // name first, then fall back to the first installable asset. + // This makes monorepo linking nearly zero-effort: pick "Ente + // Auth" → the filter pre-fills with "auth" so the picker + // already shows just the relevant APKs. + val suggestedFilter = + suggestFilterForLink( + deviceAppName = selectedApp.appName, + packageName = selectedApp.packageName, + firstAssetName = installableAssets.firstOrNull()?.name, + ) + _state.update { it.copy( isValidatingRepo = false, linkValidationStatus = null, linkStep = LinkStep.PickAsset, linkInstallableAssets = installableAssets, + linkAssetFilter = suggestedFilter.orEmpty(), + linkAssetFilterError = null, + linkFallbackToOlder = false, ) } } catch (_: RateLimitException) { @@ -1018,7 +1326,12 @@ class AppsViewModel( val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) if (apkInfo == null) { logger.debug("Could not extract APK info for validation, linking anyway") - appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo.toDomain()) + appsRepository.linkAppToRepo( + deviceApp = selectedApp.toDomain(), + repoInfo = repoInfo.toDomain(), + assetFilterRegex = _state.value.linkAssetFilter.takeIf { it.isNotBlank() }, + fallbackToOlderReleases = _state.value.linkFallbackToOlder, + ) _state.update { it.copy( linkDownloadProgress = null, @@ -1070,7 +1383,12 @@ class AppsViewModel( return@launch } - appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo.toDomain()) + appsRepository.linkAppToRepo( + deviceApp = selectedApp.toDomain(), + repoInfo = repoInfo.toDomain(), + assetFilterRegex = _state.value.linkAssetFilter.takeIf { it.isNotBlank() }, + fallbackToOlderReleases = _state.value.linkFallbackToOlder, + ) _state.update { it.copy( linkDownloadProgress = null, 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 new file mode 100644 index 000000000..a2e4af605 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt @@ -0,0 +1,365 @@ +package zed.rainxch.apps.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.apps.presentation.AppsAction +import zed.rainxch.apps.presentation.AppsState +import zed.rainxch.apps.presentation.model.GithubAssetUi +import zed.rainxch.githubstore.core.presentation.res.* + +/** + * Per-app advanced settings sheet for monorepo support. Shows: + * - Asset filter (regex) text field with inline validation + * - Fall-back-to-older-releases toggle + * - **Live preview** of which assets in the latest matching release the + * current draft would resolve to. This is the killer UX touch — users + * can iterate on the regex and immediately see the effect without + * having to save and run an update check. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdvancedAppSettingsBottomSheet( + state: AppsState, + onAction: (AppsAction) -> Unit, +) { + val app = state.advancedSettingsApp ?: return + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = { onAction(AppsAction.OnDismissAdvancedSettings) }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.FilterAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.advanced_settings_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${app.repoOwner}/${app.repoName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.advanced_settings_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(20.dp)) + + // === Asset filter === + OutlinedTextField( + value = state.advancedFilterDraft, + onValueChange = { onAction(AppsAction.OnAdvancedFilterChanged(it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(Res.string.asset_filter_label)) }, + placeholder = { Text(stringResource(Res.string.asset_filter_placeholder)) }, + leadingIcon = { + Icon(Icons.Default.FilterAlt, contentDescription = null) + }, + trailingIcon = { + if (state.advancedFilterDraft.isNotEmpty()) { + TextButton(onClick = { onAction(AppsAction.OnAdvancedClearFilter) }) { + Text(stringResource(Res.string.clear)) + } + } + }, + singleLine = true, + isError = state.advancedFilterError != null, + supportingText = { + Text( + text = + when { + state.advancedFilterError != null -> + stringResource(Res.string.asset_filter_invalid) + else -> stringResource(Res.string.asset_filter_help) + }, + color = + if (state.advancedFilterError != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + enabled = !state.advancedSavingFilter, + shape = RoundedCornerShape(12.dp), + ) + + Spacer(Modifier.height(12.dp)) + + // === Fallback toggle === + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.fallback_older_releases_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(Res.string.fallback_older_releases_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = state.advancedFallbackDraft, + onCheckedChange = { onAction(AppsAction.OnAdvancedFallbackToggled(it)) }, + enabled = !state.advancedSavingFilter, + ) + } + + Spacer(Modifier.height(20.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + Spacer(Modifier.height(16.dp)) + + // === Live preview === + PreviewSection( + isLoading = state.advancedPreviewLoading, + matchedAssets = state.advancedPreviewMatched, + matchedTag = state.advancedPreviewTag, + message = state.advancedPreviewMessage, + onRefresh = { onAction(AppsAction.OnAdvancedRefreshPreview) }, + ) + + Spacer(Modifier.height(20.dp)) + + // === Save / cancel buttons === + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { onAction(AppsAction.OnDismissAdvancedSettings) }, + enabled = !state.advancedSavingFilter, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(Res.string.cancel)) + } + FilledTonalButton( + onClick = { onAction(AppsAction.OnAdvancedSaveFilter) }, + enabled = !state.advancedSavingFilter && state.advancedFilterError == null, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + ) { + if (state.advancedSavingFilter) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text( + text = stringResource(Res.string.advanced_save), + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} + +@Composable +private fun PreviewSection( + isLoading: Boolean, + matchedAssets: ImmutableList, + matchedTag: String?, + message: String?, + onRefresh: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.advanced_preview_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onRefresh, enabled = !isLoading) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(Res.string.advanced_preview_refresh), + ) + } + } + + Spacer(Modifier.height(4.dp)) + + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 2.dp, + ) + } + } + + message == "no_match" -> { + Text( + text = stringResource(Res.string.advanced_preview_no_match), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + message == "preview_failed" || message == "save_failed" -> { + Text( + text = stringResource(Res.string.advanced_preview_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + matchedAssets.isEmpty() -> { + Text( + text = stringResource(Res.string.advanced_preview_pending), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + else -> { + if (matchedTag != null) { + Text( + text = + stringResource( + Res.string.advanced_preview_release, + matchedTag, + matchedAssets.size, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 0.dp, max = 180.dp), + ) { + items(matchedAssets, key = { it.id }) { asset -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = asset.name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = formatPreviewSize(asset.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +private fun formatPreviewSize(bytes: Long): String = + when { + bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) + else -> "$bytes B" + } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt index 606963037..dcc1eddbc 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,6 +34,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults @@ -98,11 +100,17 @@ fun LinkAppBottomSheet( ) LinkStep.PickAsset -> PickAssetStep( - assets = state.linkInstallableAssets, + allAssets = state.linkInstallableAssets, + visibleAssets = state.filteredLinkAssets, selectedAsset = state.linkSelectedAsset, downloadProgress = state.linkDownloadProgress, validationStatus = state.linkValidationStatus, validationError = state.repoValidationError, + filterValue = state.linkAssetFilter, + filterError = state.linkAssetFilterError, + fallbackEnabled = state.linkFallbackToOlder, + onFilterChanged = { onAction(AppsAction.OnLinkAssetFilterChanged(it)) }, + onFallbackToggled = { onAction(AppsAction.OnLinkFallbackToggled(it)) }, onAssetSelected = { onAction(AppsAction.OnLinkAssetSelected(it)) }, onBack = { onAction(AppsAction.OnBackToEnterUrl) }, ) @@ -367,11 +375,17 @@ private fun EnterUrlStep( @Composable private fun PickAssetStep( - assets: List, + allAssets: List, + visibleAssets: List, selectedAsset: GithubAssetUi?, downloadProgress: Int?, validationStatus: String?, validationError: String?, + filterValue: String, + filterError: String?, + fallbackEnabled: Boolean, + onFilterChanged: (String) -> Unit, + onFallbackToggled: (Boolean) -> Unit, onAssetSelected: (GithubAssetUi) -> Unit, onBack: () -> Unit, ) { @@ -411,13 +425,90 @@ private fun PickAssetStep( Spacer(Modifier.height(12.dp)) + // Asset filter — for monorepos that ship multiple apps from the + // same repo. Live-narrows the visible list and is persisted with + // the link, so the update checker only ever resolves matching APKs. + OutlinedTextField( + value = filterValue, + onValueChange = onFilterChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(Res.string.asset_filter_label)) }, + placeholder = { Text(stringResource(Res.string.asset_filter_placeholder)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FilterAlt, + contentDescription = null, + ) + }, + singleLine = true, + isError = filterError != null, + supportingText = { + Text( + text = + when { + filterError != null -> stringResource(Res.string.asset_filter_invalid) + visibleAssets.isEmpty() && filterValue.isNotBlank() -> + stringResource(Res.string.asset_filter_no_match) + filterValue.isNotBlank() -> + stringResource( + Res.string.asset_filter_visible_count, + visibleAssets.size, + allAssets.size, + ) + else -> stringResource(Res.string.asset_filter_help) + }, + color = + if (filterError != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + enabled = !isProcessing, + shape = RoundedCornerShape(12.dp), + ) + + Spacer(Modifier.height(8.dp)) + + // Fall-back-to-older-releases toggle. Only meaningful when a filter + // is set; in monorepos, the latest release is often for the wrong + // app, so the checker needs to walk back to find this app's APK. + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isProcessing) { onFallbackToggled(!fallbackEnabled) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.fallback_older_releases_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(Res.string.fallback_older_releases_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = fallbackEnabled, + onCheckedChange = onFallbackToggled, + enabled = !isProcessing, + ) + } + + Spacer(Modifier.height(8.dp)) + LazyColumn( modifier = Modifier .fillMaxWidth() - .height(300.dp), + .height(260.dp), ) { items( - items = assets, + items = visibleAssets, key = { it.id }, ) { asset -> val isSelected = selectedAsset?.id == asset.id @@ -474,6 +565,23 @@ private fun PickAssetStep( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), ) } + + if (visibleAssets.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.asset_filter_no_match), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } if (validationStatus != null) { 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 be1b00248..c988ee132 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 @@ -36,4 +36,6 @@ data class InstalledAppUi( val latestVersionCode: Long? = null, val latestReleasePublishedAt: String? = null, val includePreReleases: Boolean = false, + val assetFilterRegex: String? = null, + val fallbackToOlderReleases: Boolean = false, ) From 82a9cb08c972f6b0ae88a69e58ab6c6993f59c63 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 10 Apr 2026 23:28:32 +0500 Subject: [PATCH 5/6] locales: add strings for monorepo support and advanced asset filtering Introduce a new set of localized strings to support advanced repository filtering, specifically targeting monorepos with multiple applications. These changes include: - Added labels and descriptions for asset filtering using regular expressions. - Added support for "fallback to older releases," allowing the app to search through previous releases when the most recent one doesn't match the filter. - Added strings for advanced settings, including preview functionality, validation states, and save actions. - Provided consistent translations for these new features across Turkish, Italian, Arabic, Simplified Chinese, Bengali, French, Russian, Japanese, Hindi, Korean, Spanish, and Polish. --- .../composeResources/values-ar/strings-ar.xml | 20 +++++++++++++++++++ .../composeResources/values-bn/strings-bn.xml | 20 +++++++++++++++++++ .../composeResources/values-es/strings-es.xml | 20 +++++++++++++++++++ .../composeResources/values-fr/strings-fr.xml | 20 +++++++++++++++++++ .../composeResources/values-hi/strings-hi.xml | 20 +++++++++++++++++++ .../composeResources/values-it/strings-it.xml | 20 +++++++++++++++++++ .../composeResources/values-ja/strings-ja.xml | 20 +++++++++++++++++++ .../composeResources/values-ko/strings-ko.xml | 20 +++++++++++++++++++ .../composeResources/values-pl/strings-pl.xml | 20 +++++++++++++++++++ .../composeResources/values-ru/strings-ru.xml | 20 +++++++++++++++++++ .../composeResources/values-tr/strings-tr.xml | 20 +++++++++++++++++++ .../values-zh-rCN/strings-zh-rCN.xml | 20 +++++++++++++++++++ 12 files changed, 240 insertions(+) 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 843a84460..7bcbad363 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -631,4 +631,24 @@ تعديلات تعديلات إصدارات تجريبية + + + عامل تصفية الأصول + مثال: ente-auth + سيتم تثبيت الأصول المطابقة لهذا النمط (regex) فقط. مفيد للمستودعات التي تحتوي على عدة تطبيقات. + تعبير عادي غير صالح + لا توجد أصول مطابقة لهذا الفلتر + عرض %1$d من %2$d أصل + الرجوع إلى الإصدارات الأقدم + المرور عبر الإصدارات السابقة حتى يتطابق أحدها مع الفلتر. مطلوب للمستودعات التي يكون فيها أحدث إصدار لتطبيق آخر. + فلتر متقدم + قم بضبط كيفية مطابقة هذا التطبيق في المستودع. استخدم هذه الإعدادات عندما يحتوي المستودع على عدة تطبيقات. + فتح الفلتر المتقدم + حفظ + معاينة + تحديث المعاينة + اكتب فلترًا لمعاينة الأصول المطابقة. + لا توجد إصدارات حديثة تحتوي على أصول مطابقة. حاول تفعيل الرجوع إلى الإصدارات الأقدم أو ضبط النمط. + تعذر تحميل المعاينة. تحقق من اتصالك وحاول مرة أخرى. + مطابقة في %1$s · %2$d أصل 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 9ecb51713..3806bfc27 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -630,4 +630,24 @@ টুইকস টুইকস প্রি-রিলিজ + + + অ্যাসেট ফিল্টার + যেমন: ente-auth + শুধুমাত্র এই প্যাটার্নের (regex) সাথে মেলে এমন অ্যাসেট ইনস্টল করা হবে। মনোরিপোর জন্য উপযোগী। + অবৈধ regex + এই ফিল্টারের সাথে মেলে এমন কোনো অ্যাসেট নেই + %2$d-এর মধ্যে %1$d দেখানো হচ্ছে + পুরোনো রিলিজে ফিরে যান + ফিল্টারের সাথে মেলে এমন একটি না পাওয়া পর্যন্ত পূর্ববর্তী রিলিজগুলো দেখুন। মনোরিপোর জন্য প্রয়োজনীয় যেখানে সর্বশেষ রিলিজ অন্য অ্যাপের। + উন্নত ফিল্টার + এই অ্যাপটি কীভাবে রিপোজিটরিতে মিলবে তা কনফিগার করুন। যখন রিপো একাধিক অ্যাপ পাঠায় তখন এই সেটিংস ব্যবহার করুন। + উন্নত ফিল্টার খুলুন + সংরক্ষণ + প্রিভিউ + প্রিভিউ রিফ্রেশ + মেলে এমন অ্যাসেট প্রিভিউ করতে একটি ফিল্টার টাইপ করুন। + সাম্প্রতিক উইন্ডোতে কোনো রিলিজে মিলে যাওয়া অ্যাসেট নেই। পুরোনো রিলিজে ফিরে যাওয়া সক্রিয় করুন বা regex সমন্বয় করুন। + প্রিভিউ লোড করা যায়নি। আপনার সংযোগ পরীক্ষা করে আবার চেষ্টা করুন। + %1$s-এ মিলেছে · %2$d অ্যাসেট 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 34cf5e9b0..71ecb5266 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -591,4 +591,24 @@ Ajustes Ajustes Pre-lanzamientos + + + Filtro de assets + p. ej. ente-auth + Solo se instalarán los assets que coincidan con este patrón (regex). Útil para monorepos con varias apps. + Regex no válido + Ningún asset coincide con este filtro + Mostrando %1$d de %2$d assets + Recurrir a versiones antiguas + Recorre versiones anteriores hasta encontrar una que coincida con el filtro. Necesario en monorepos donde la última versión pertenece a otra app. + Filtro avanzado + Configura cómo se identifica esta app en el repositorio. Útil cuando el repo distribuye varias apps. + Abrir filtro avanzado + Guardar + Vista previa + Actualizar vista previa + Escribe un filtro para previsualizar los assets coincidentes. + Ninguna versión reciente contiene assets que coincidan. Activa el modo "versiones antiguas" o ajusta el regex. + No se pudo cargar la vista previa. Comprueba tu conexión e inténtalo de nuevo. + Coincidencia en %1$s · %2$d asset(s) \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 3af132c12..faa8f5201 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -592,4 +592,24 @@ Réglages Réglages Pré-versions + + + Filtre d\'assets + ex : ente-auth + Seuls les assets correspondant à ce motif (regex) seront installés. Utile pour les monorepos contenant plusieurs apps. + Regex invalide + Aucun asset ne correspond à ce filtre + %1$d sur %2$d assets affichés + Recourir aux anciennes versions + Parcourir les versions précédentes jusqu\'à en trouver une qui corresponde au filtre. Nécessaire pour les monorepos où la dernière version appartient à une autre app. + Filtre avancé + Configurez comment cette app est identifiée dans le dépôt. Utile lorsque le dépôt contient plusieurs apps. + Ouvrir le filtre avancé + Enregistrer + Aperçu + Actualiser l\'aperçu + Saisissez un filtre pour prévisualiser les assets correspondants. + Aucune version récente ne contient d\'asset correspondant. Activez le repli vers les anciennes versions ou ajustez le regex. + Impossible de charger l\'aperçu. Vérifiez votre connexion et réessayez. + Correspondance dans %1$s · %2$d asset(s) \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 23ce87cef..b8ca64d4d 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -629,4 +629,24 @@ ट्वीक्स ट्वीक्स प्री-रिलीज़ + + + एसेट फ़िल्टर + उदा. ente-auth + केवल इस पैटर्न (regex) से मेल खाने वाले एसेट इंस्टॉल किए जाएंगे। मोनोरिपो के लिए उपयोगी। + अमान्य regex + कोई भी एसेट इस फ़िल्टर से मेल नहीं खाता + %2$d में से %1$d एसेट दिखाए जा रहे हैं + पुराने रिलीज़ पर वापस जाएँ + फ़िल्टर से मेल खाने वाला कोई न मिलने तक पिछले रिलीज़ खंगालें। मोनोरिपो के लिए आवश्यक जहाँ नवीनतम रिलीज़ किसी अन्य ऐप का है। + उन्नत फ़िल्टर + कॉन्फ़िगर करें कि यह ऐप रिपॉज़िटरी में कैसे मेल खाता है। जब रिपो कई ऐप्स पैक करता है तब इन सेटिंग्स का उपयोग करें। + उन्नत फ़िल्टर खोलें + सहेजें + पूर्वावलोकन + पूर्वावलोकन रीफ़्रेश करें + मेल खाने वाले एसेट देखने के लिए फ़िल्टर टाइप करें। + हाल के रिलीज़ में कोई मेल खाने वाला एसेट नहीं मिला। पुराने रिलीज़ पर फ़ॉलबैक चालू करें या regex समायोजित करें। + पूर्वावलोकन लोड नहीं हो सका। अपना कनेक्शन जाँचें और पुनः प्रयास करें। + %1$s में मिला · %2$d एसेट 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 fc49c3a11..2e342767e 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -630,4 +630,24 @@ Modifiche Modifiche Pre-release + + + Filtro asset + es. ente-auth + Verranno installati solo gli asset che corrispondono a questo pattern (regex). Utile per monorepo con più app. + Regex non valida + Nessun asset corrisponde a questo filtro + Mostrati %1$d di %2$d asset + Risalire alle release precedenti + Scorri le release precedenti finché una non corrisponde al filtro. Necessario per i monorepo in cui l\'ultima release riguarda un\'altra app. + Filtro avanzato + Configura come questa app viene identificata nel repository. Usa queste impostazioni quando il repo contiene più app. + Apri filtro avanzato + Salva + Anteprima + Aggiorna anteprima + Inserisci un filtro per visualizzare gli asset corrispondenti. + Nessuna release recente contiene asset corrispondenti. Attiva il fallback alle release precedenti o modifica la regex. + Impossibile caricare l\'anteprima. Controlla la connessione e riprova. + Corrispondenza in %1$s · %2$d asset \ 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 d76151534..05b74db5b 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -593,4 +593,24 @@ 調整 調整 プレリリース + + + アセットフィルター + 例: ente-auth + このパターン (regex) に一致するアセットのみインストールされます。複数のアプリを含むモノレポに便利です。 + 無効な正規表現 + このフィルターに一致するアセットはありません + %2$d 件中 %1$d 件のアセットを表示 + 古いリリースへフォールバック + フィルターに一致するものが見つかるまで過去のリリースを遡ります。最新リリースが別のアプリのものであるモノレポで必要です。 + 詳細フィルター + このアプリがリポジトリ内でどのように識別されるかを設定します。リポジトリが複数のアプリを配布する場合に使用します。 + 詳細フィルターを開く + 保存 + プレビュー + プレビューを更新 + フィルターを入力して一致するアセットをプレビューします。 + 最近のリリースに一致するアセットがありません。古いリリースへのフォールバックを有効にするか、正規表現を調整してください。 + プレビューを読み込めませんでした。接続を確認してもう一度お試しください。 + %1$s で一致 · %2$d 個のアセット \ 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 2d99333cd..37554fd3e 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -628,4 +628,24 @@ 조정 조정 프리릴리스 + + + 에셋 필터 + 예: ente-auth + 이 패턴 (regex)과 일치하는 에셋만 설치됩니다. 여러 앱을 배포하는 모노레포에 유용합니다. + 잘못된 정규식 + 이 필터와 일치하는 에셋이 없습니다 + %2$d개 중 %1$d개 에셋 표시 중 + 이전 릴리스로 폴백 + 필터와 일치하는 릴리스를 찾을 때까지 이전 릴리스를 거슬러 올라갑니다. 최신 릴리스가 다른 앱에 속한 모노레포에서 필요합니다. + 고급 필터 + 이 앱이 저장소 내에서 어떻게 식별되는지 구성합니다. 저장소에 여러 앱이 포함된 경우 사용하세요. + 고급 필터 열기 + 저장 + 미리보기 + 미리보기 새로고침 + 일치하는 에셋을 미리 보려면 필터를 입력하세요. + 최근 릴리스에 일치하는 에셋이 없습니다. 이전 릴리스 폴백을 활성화하거나 정규식을 조정해보세요. + 미리보기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요. + %1$s에서 일치 · %2$d개 에셋 \ 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 756f35723..75556fb83 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -594,4 +594,24 @@ Ustawienia Ustawienia Wersje wstępne + + + Filtr zasobów + np. ente-auth + Zainstalowane zostaną tylko zasoby pasujące do tego wzorca (regex). Przydatne w monorepach z wieloma aplikacjami. + Nieprawidłowy regex + Żaden zasób nie pasuje do tego filtra + Pokazano %1$d z %2$d zasobów + Wróć do starszych wydań + Przeszukaj wcześniejsze wydania, aż znajdziesz pasujące do filtra. Wymagane dla monorepów, w których najnowsze wydanie należy do innej aplikacji. + Filtr zaawansowany + Skonfiguruj sposób identyfikacji tej aplikacji w repozytorium. Użyj tych ustawień, gdy repo zawiera wiele aplikacji. + Otwórz filtr zaawansowany + Zapisz + Podgląd + Odśwież podgląd + Wpisz filtr, aby zobaczyć pasujące zasoby. + Żadne ostatnie wydanie nie zawiera pasujących zasobów. Włącz powrót do starszych wydań lub dostosuj regex. + Nie można załadować podglądu. Sprawdź połączenie i spróbuj ponownie. + Pasuje w %1$s · %2$d zasób(y) \ 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 33683cd1e..cc909f013 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -594,4 +594,24 @@ Настройки Настройки Пре-релизы + + + Фильтр ассетов + напр. ente-auth + Будут установлены только ассеты, соответствующие этому шаблону (regex). Полезно для монорепо с несколькими приложениями. + Недопустимое регулярное выражение + Ни один ассет не соответствует фильтру + Показано %1$d из %2$d ассетов + Возврат к старым релизам + Просматривать предыдущие релизы, пока не найдётся соответствующий фильтру. Необходимо для монорепо, где последний релиз принадлежит другому приложению. + Расширенный фильтр + Настройте, как это приложение определяется в репозитории. Используйте эти настройки, если репо содержит несколько приложений. + Открыть расширенный фильтр + Сохранить + Предпросмотр + Обновить предпросмотр + Введите фильтр, чтобы увидеть подходящие ассеты. + В последних релизах нет подходящих ассетов. Включите возврат к старым релизам или измените regex. + Не удалось загрузить предпросмотр. Проверьте подключение и попробуйте снова. + Совпадение в %1$s · %2$d ассет(ов) \ 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 f43b397d5..85a0686dd 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -628,4 +628,24 @@ İnce Ayarlar İnce Ayarlar Ön sürümler + + + Varlık filtresi + örn. ente-auth + Yalnızca bu desene (regex) uyan varlıklar yüklenir. Birden fazla uygulama içeren monorepolar için kullanışlıdır. + Geçersiz regex + Bu filtreye uyan varlık yok + %2$d varlığın %1$d tanesi gösteriliyor + Eski sürümlere geri dön + Filtreye uyan biri bulunana kadar önceki sürümlere göz at. Son sürümün başka bir uygulamaya ait olduğu monorepolar için gereklidir. + Gelişmiş filtre + Bu uygulamanın depoda nasıl eşleştiğini yapılandırın. Depo birden fazla uygulama dağıttığında bu ayarları kullanın. + Gelişmiş filtreyi aç + Kaydet + Önizleme + Önizlemeyi yenile + Eşleşen varlıkları önizlemek için bir filtre yazın. + Son sürümlerde eşleşen varlık yok. Eski sürümlere geri dönmeyi etkinleştirin veya regex\'i ayarlayın. + Önizleme yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin. + %1$s sürümünde eşleşti · %2$d varlık 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 2c0086818..a1a707644 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 @@ -594,4 +594,24 @@ 调整 调整 预发布版本 + + + 资产过滤器 + 例如:ente-auth + 仅安装与此模式 (regex) 匹配的资产。适用于包含多个应用的单一仓库。 + 无效的正则表达式 + 没有匹配此过滤器的资产 + 显示 %2$d 个资产中的 %1$d 个 + 回退到旧版本 + 遍历过去的版本,直到找到匹配过滤器的版本。最新版本属于其他应用的单一仓库需要此功能。 + 高级过滤器 + 配置此应用在仓库中的匹配方式。当仓库包含多个应用时使用这些设置。 + 打开高级过滤器 + 保存 + 预览 + 刷新预览 + 输入过滤器以预览匹配的资产。 + 最近的版本中没有匹配的资产。请启用回退到旧版本或调整正则表达式。 + 无法加载预览。请检查您的连接并重试。 + 在 %1$s 中匹配 · %2$d 个资产 \ No newline at end of file From 5cfa2fcc7299fda2e3eb3c41897f6e88163f51e5 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 11 Apr 2026 06:32:50 +0500 Subject: [PATCH 6/6] Summarize the following changes: - **Localization & Resources**: - Convert `asset_filter_visible_count` and `advanced_preview_release` to plurals to support proper inflection across all supported languages (English, Arabic, Bengali, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Russian, Turkish, and Simplified Chinese). - Add `asset_none_available` string for releases with no installable assets. - Update UI components to use `pluralStringResource`. - **Core Data & Logic**: - Add `clearUpdateMetadata` to `InstalledAppDao` to atomically reset update badges and stale metadata when filters no longer match any assets. - Improve `AssetFilter.suggestFromAssetName` to return an escaped, anchored literal-prefix regex (e.g., `^\Qprefix\E`) to prevent metacharacters from breaking searches. - Update `AppsRepository` to include `assetFilterRegex` and `fallbackToOlderReleases` in the export schema (bumped to version 2). - Enhance `AppsViewModel` regex suggestion logic to automatically escape package segments and display name tokens. - **Bug Fixes & Improvements**: - Ensure `checkForUpdates` clears stale metadata when no matching releases are found in the current window. - Fix structured concurrency by ensuring `CancellationException` is propagated during release fetches and filter updates. - Improve "Link App" UI to distinguish between an invalid regex, no matches, and no available assets. - Optimize `setAssetFilter` to perform an internal update check immediately after persisting changes. --- .../core/data/local/db/dao/InstalledAppDao.kt | 29 ++++++++++++++++ .../repository/InstalledAppsRepositoryImpl.kt | 29 ++++++++++++++-- .../rainxch/core/domain/model/ExportedApp.kt | 11 ++++++- .../rainxch/core/domain/util/AssetFilter.kt | 20 ++++++----- .../composeResources/values-ar/strings-ar.xml | 11 +++++-- .../composeResources/values-bn/strings-bn.xml | 11 +++++-- .../composeResources/values-es/strings-es.xml | 11 +++++-- .../composeResources/values-fr/strings-fr.xml | 11 +++++-- .../composeResources/values-hi/strings-hi.xml | 11 +++++-- .../composeResources/values-it/strings-it.xml | 11 +++++-- .../composeResources/values-ja/strings-ja.xml | 9 +++-- .../composeResources/values-ko/strings-ko.xml | 9 +++-- .../composeResources/values-pl/strings-pl.xml | 15 +++++++-- .../composeResources/values-ru/strings-ru.xml | 15 +++++++-- .../composeResources/values-tr/strings-tr.xml | 11 +++++-- .../values-zh-rCN/strings-zh-rCN.xml | 9 +++-- .../composeResources/values/strings.xml | 11 +++++-- .../data/repository/AppsRepositoryImpl.kt | 11 +++++-- .../apps/presentation/AppsViewModel.kt | 30 +++++++++-------- .../AdvancedAppSettingsBottomSheet.kt | 6 ++-- .../components/LinkAppBottomSheet.kt | 33 ++++++++++++++++--- 21 files changed, 256 insertions(+), 58 deletions(-) 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 4a93ad7d3..348d5a3c8 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 @@ -93,4 +93,33 @@ interface InstalledAppDao { packageName: String, timestamp: Long, ) + + /** + * Atomically clears the "update available" badge and any cached + * latest-release metadata for [packageName], while bumping + * `lastCheckedAt`. Used by `checkForUpdates` whenever the current + * filter / release window yields no match: without this, a user who + * tightens their asset filter would keep the stale badge and the + * download button would point at an asset that no longer matches. + */ + @Query( + """ + UPDATE installed_apps + SET isUpdateAvailable = 0, + latestVersion = NULL, + latestAssetName = NULL, + latestAssetUrl = NULL, + latestAssetSize = NULL, + latestVersionName = NULL, + latestVersionCode = NULL, + latestReleasePublishedAt = NULL, + releaseNotes = NULL, + lastCheckedAt = :timestamp + WHERE packageName = :packageName + """, + ) + suspend fun clearUpdateMetadata( + packageName: String, + timestamp: Long, + ) } 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 8ed594bba..20080c06f 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 @@ -8,6 +8,7 @@ import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.HttpHeaders +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -117,6 +118,10 @@ class InstalledAppsRepositoryImpl( .sortedByDescending { it.publishedAt ?: it.createdAt ?: "" } .map { it.toDomain() } .toList() + } catch (e: CancellationException) { + // Structured concurrency: cancellation must propagate. Never + // silently convert a cancelled fetch into an empty result. + throw e } catch (e: Exception) { Logger.e { "Failed to fetch releases for $owner/$repo: ${e.message}" } emptyList() @@ -187,7 +192,10 @@ class InstalledAppsRepositoryImpl( ) if (releases.isEmpty()) { - installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) + // The repo has no visible releases (or the fetch failed + // softly). Drop any stale update metadata so the badge + // doesn't outlive the release that set it. + installedAppsDao.clearUpdateMetadata(packageName, System.currentTimeMillis()) return false } @@ -215,7 +223,10 @@ class InstalledAppsRepositoryImpl( "No matching release found for ${app.appName} in window of ${releases.size}; " + "filter=${app.assetFilterRegex}, fallback=${app.fallbackToOlderReleases}" } - installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) + // Filter matches nothing in the fetched window — clear + // any cached latest-release metadata so the UI doesn't + // keep pointing at an asset that no longer matches. + installedAppsDao.clearUpdateMetadata(packageName, System.currentTimeMillis()) return false } @@ -355,6 +366,20 @@ class InstalledAppsRepositoryImpl( regex = normalized, fallback = fallbackToOlderReleases, ) + + // Persisting is the authoritative operation — if the follow-up + // re-check fails (network down, rate limited, cancelled) we still + // keep the new filter. The next periodic worker run will catch up. + try { + checkForUpdates(packageName) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w { + "Saved new asset filter for $packageName but immediate " + + "re-check failed: ${e.message}" + } + } } override suspend fun previewMatchingAssets( 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 a92d1a2f7..76f94a94d 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 @@ -8,11 +8,20 @@ data class ExportedApp( val repoOwner: String, val repoName: String, val repoUrl: String, + // Monorepo tracking (added in export schema v2). Defaults keep + // old v1 JSON files decoding without changes. + val assetFilterRegex: String? = null, + val fallbackToOlderReleases: Boolean = false, ) @Serializable data class ExportedAppList( - val version: Int = 1, + /** + * Export schema version. Bumped to 2 when monorepo fields were added + * to [ExportedApp]. Older v1 exports still decode correctly because + * the new fields have safe defaults. + */ + val version: Int = 2, val exportedAt: Long = 0L, val apps: List = emptyList(), ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt index d03445c69..966b3efd1 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt @@ -44,15 +44,18 @@ class AssetFilter private constructor( } /** - * Suggests a sensible filter from a sample asset name. Strips the - * version suffix (anything from the first `-` onward) and - * returns the leading prefix as a literal-prefix anchor. + * Suggests a sensible filter regex from a sample asset name. + * Strips the version suffix (anything from the first `-` + * onward) and returns the leading prefix as a **literal-prefix + * regex** — escaped and anchored to the start of the filename so + * metacharacters in the prefix don't get interpreted as regex + * operators. * * Examples: - * ente-auth-3.2.5-arm64-v8a.apk → ente-auth- - * Photos-1.7.0-universal.apk → Photos- - * app_2024-01-15.apk → app_ - * no-version.apk → null (cannot derive a useful prefix) + * ente-auth-3.2.5-arm64-v8a.apk → ^\Qente-auth-\E + * Photos-1.7.0-universal.apk → ^\QPhotos-\E + * app+1.2.3.apk → ^\Qapp+\E (the `+` is escaped) + * no-version.apk → null (no clear version anchor) * * Returns `null` when the asset name has no clear version anchor — * blindly returning the full filename would create a filter that @@ -64,7 +67,8 @@ class AssetFilter private constructor( val match = versionAnchor.find(assetName) ?: return null val prefix = assetName.substring(0, match.range.first + 1) // Need at least 2 meaningful chars; otherwise the suggestion is noise. - return prefix.takeIf { it.length >= 2 } + if (prefix.length < 2) return null + return "^" + Regex.escape(prefix) } } } 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 7bcbad363..7e73c50e4 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -638,7 +638,11 @@ سيتم تثبيت الأصول المطابقة لهذا النمط (regex) فقط. مفيد للمستودعات التي تحتوي على عدة تطبيقات. تعبير عادي غير صالح لا توجد أصول مطابقة لهذا الفلتر - عرض %1$d من %2$d أصل + لا توجد أصول قابلة للتثبيت في هذا الإصدار + + عرض %1$d من %2$d أصل + عرض %1$d من %2$d أصل + الرجوع إلى الإصدارات الأقدم المرور عبر الإصدارات السابقة حتى يتطابق أحدها مع الفلتر. مطلوب للمستودعات التي يكون فيها أحدث إصدار لتطبيق آخر. فلتر متقدم @@ -650,5 +654,8 @@ اكتب فلترًا لمعاينة الأصول المطابقة. لا توجد إصدارات حديثة تحتوي على أصول مطابقة. حاول تفعيل الرجوع إلى الإصدارات الأقدم أو ضبط النمط. تعذر تحميل المعاينة. تحقق من اتصالك وحاول مرة أخرى. - مطابقة في %1$s · %2$d أصل + + مطابقة في %1$s · %2$d أصل + مطابقة في %1$s · %2$d أصل + 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 3806bfc27..37459469b 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -637,7 +637,11 @@ শুধুমাত্র এই প্যাটার্নের (regex) সাথে মেলে এমন অ্যাসেট ইনস্টল করা হবে। মনোরিপোর জন্য উপযোগী। অবৈধ regex এই ফিল্টারের সাথে মেলে এমন কোনো অ্যাসেট নেই - %2$d-এর মধ্যে %1$d দেখানো হচ্ছে + এই রিলিজে কোনো ইনস্টলযোগ্য অ্যাসেট নেই + + %2$d-এর মধ্যে %1$d দেখানো হচ্ছে + %2$d-এর মধ্যে %1$d দেখানো হচ্ছে + পুরোনো রিলিজে ফিরে যান ফিল্টারের সাথে মেলে এমন একটি না পাওয়া পর্যন্ত পূর্ববর্তী রিলিজগুলো দেখুন। মনোরিপোর জন্য প্রয়োজনীয় যেখানে সর্বশেষ রিলিজ অন্য অ্যাপের। উন্নত ফিল্টার @@ -649,5 +653,8 @@ মেলে এমন অ্যাসেট প্রিভিউ করতে একটি ফিল্টার টাইপ করুন। সাম্প্রতিক উইন্ডোতে কোনো রিলিজে মিলে যাওয়া অ্যাসেট নেই। পুরোনো রিলিজে ফিরে যাওয়া সক্রিয় করুন বা regex সমন্বয় করুন। প্রিভিউ লোড করা যায়নি। আপনার সংযোগ পরীক্ষা করে আবার চেষ্টা করুন। - %1$s-এ মিলেছে · %2$d অ্যাসেট + + %1$s-এ মিলেছে · %2$d অ্যাসেট + %1$s-এ মিলেছে · %2$d অ্যাসেট + 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 71ecb5266..127bd7d7c 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -598,7 +598,11 @@ Solo se instalarán los assets que coincidan con este patrón (regex). Útil para monorepos con varias apps. Regex no válido Ningún asset coincide con este filtro - Mostrando %1$d de %2$d assets + No hay assets instalables en esta versión + + Mostrando %1$d de %2$d asset + Mostrando %1$d de %2$d assets + Recurrir a versiones antiguas Recorre versiones anteriores hasta encontrar una que coincida con el filtro. Necesario en monorepos donde la última versión pertenece a otra app. Filtro avanzado @@ -610,5 +614,8 @@ Escribe un filtro para previsualizar los assets coincidentes. Ninguna versión reciente contiene assets que coincidan. Activa el modo "versiones antiguas" o ajusta el regex. No se pudo cargar la vista previa. Comprueba tu conexión e inténtalo de nuevo. - Coincidencia en %1$s · %2$d asset(s) + + Coincidencia en %1$s · %2$d asset + Coincidencia en %1$s · %2$d assets + \ 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 faa8f5201..499151e6f 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -599,7 +599,11 @@ Seuls les assets correspondant à ce motif (regex) seront installés. Utile pour les monorepos contenant plusieurs apps. Regex invalide Aucun asset ne correspond à ce filtre - %1$d sur %2$d assets affichés + Aucun asset installable dans cette version + + %1$d sur %2$d asset affiché + %1$d sur %2$d assets affichés + Recourir aux anciennes versions Parcourir les versions précédentes jusqu\'à en trouver une qui corresponde au filtre. Nécessaire pour les monorepos où la dernière version appartient à une autre app. Filtre avancé @@ -611,5 +615,8 @@ Saisissez un filtre pour prévisualiser les assets correspondants. Aucune version récente ne contient d\'asset correspondant. Activez le repli vers les anciennes versions ou ajustez le regex. Impossible de charger l\'aperçu. Vérifiez votre connexion et réessayez. - Correspondance dans %1$s · %2$d asset(s) + + Correspondance dans %1$s · %2$d asset + Correspondance dans %1$s · %2$d assets + \ 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 b8ca64d4d..bed065b24 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -636,7 +636,11 @@ केवल इस पैटर्न (regex) से मेल खाने वाले एसेट इंस्टॉल किए जाएंगे। मोनोरिपो के लिए उपयोगी। अमान्य regex कोई भी एसेट इस फ़िल्टर से मेल नहीं खाता - %2$d में से %1$d एसेट दिखाए जा रहे हैं + इस रिलीज़ में कोई इंस्टॉल करने योग्य एसेट नहीं है + + %2$d में से %1$d एसेट दिखाया जा रहा है + %2$d में से %1$d एसेट दिखाए जा रहे हैं + पुराने रिलीज़ पर वापस जाएँ फ़िल्टर से मेल खाने वाला कोई न मिलने तक पिछले रिलीज़ खंगालें। मोनोरिपो के लिए आवश्यक जहाँ नवीनतम रिलीज़ किसी अन्य ऐप का है। उन्नत फ़िल्टर @@ -648,5 +652,8 @@ मेल खाने वाले एसेट देखने के लिए फ़िल्टर टाइप करें। हाल के रिलीज़ में कोई मेल खाने वाला एसेट नहीं मिला। पुराने रिलीज़ पर फ़ॉलबैक चालू करें या regex समायोजित करें। पूर्वावलोकन लोड नहीं हो सका। अपना कनेक्शन जाँचें और पुनः प्रयास करें। - %1$s में मिला · %2$d एसेट + + %1$s में मिला · %2$d एसेट + %1$s में मिला · %2$d एसेट + 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 2e342767e..0f656014f 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -637,7 +637,11 @@ Verranno installati solo gli asset che corrispondono a questo pattern (regex). Utile per monorepo con più app. Regex non valida Nessun asset corrisponde a questo filtro - Mostrati %1$d di %2$d asset + Nessun asset installabile in questa release + + Mostrato %1$d di %2$d asset + Mostrati %1$d di %2$d asset + Risalire alle release precedenti Scorri le release precedenti finché una non corrisponde al filtro. Necessario per i monorepo in cui l\'ultima release riguarda un\'altra app. Filtro avanzato @@ -649,5 +653,8 @@ Inserisci un filtro per visualizzare gli asset corrispondenti. Nessuna release recente contiene asset corrispondenti. Attiva il fallback alle release precedenti o modifica la regex. Impossibile caricare l\'anteprima. Controlla la connessione e riprova. - Corrispondenza in %1$s · %2$d asset + + Corrispondenza in %1$s · %2$d asset + Corrispondenza in %1$s · %2$d asset + \ 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 05b74db5b..9cf421226 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -600,7 +600,10 @@ このパターン (regex) に一致するアセットのみインストールされます。複数のアプリを含むモノレポに便利です。 無効な正規表現 このフィルターに一致するアセットはありません - %2$d 件中 %1$d 件のアセットを表示 + このリリースにインストール可能なアセットがありません + + %2$d 件中 %1$d 件のアセットを表示 + 古いリリースへフォールバック フィルターに一致するものが見つかるまで過去のリリースを遡ります。最新リリースが別のアプリのものであるモノレポで必要です。 詳細フィルター @@ -612,5 +615,7 @@ フィルターを入力して一致するアセットをプレビューします。 最近のリリースに一致するアセットがありません。古いリリースへのフォールバックを有効にするか、正規表現を調整してください。 プレビューを読み込めませんでした。接続を確認してもう一度お試しください。 - %1$s で一致 · %2$d 個のアセット + + %1$s で一致 · %2$d 個のアセット + \ 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 37554fd3e..9d48b80bc 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -635,7 +635,10 @@ 이 패턴 (regex)과 일치하는 에셋만 설치됩니다. 여러 앱을 배포하는 모노레포에 유용합니다. 잘못된 정규식 이 필터와 일치하는 에셋이 없습니다 - %2$d개 중 %1$d개 에셋 표시 중 + 이 릴리스에 설치 가능한 에셋이 없습니다 + + %2$d개 중 %1$d개 에셋 표시 중 + 이전 릴리스로 폴백 필터와 일치하는 릴리스를 찾을 때까지 이전 릴리스를 거슬러 올라갑니다. 최신 릴리스가 다른 앱에 속한 모노레포에서 필요합니다. 고급 필터 @@ -647,5 +650,7 @@ 일치하는 에셋을 미리 보려면 필터를 입력하세요. 최근 릴리스에 일치하는 에셋이 없습니다. 이전 릴리스 폴백을 활성화하거나 정규식을 조정해보세요. 미리보기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요. - %1$s에서 일치 · %2$d개 에셋 + + %1$s에서 일치 · %2$d개 에셋 + \ 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 75556fb83..70ab1c95a 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -601,7 +601,13 @@ Zainstalowane zostaną tylko zasoby pasujące do tego wzorca (regex). Przydatne w monorepach z wieloma aplikacjami. Nieprawidłowy regex Żaden zasób nie pasuje do tego filtra - Pokazano %1$d z %2$d zasobów + Brak instalowalnych zasobów w tej wersji + + Pokazano %1$d z %2$d zasobu + Pokazano %1$d z %2$d zasobów + Pokazano %1$d z %2$d zasobów + Pokazano %1$d z %2$d zasobów + Wróć do starszych wydań Przeszukaj wcześniejsze wydania, aż znajdziesz pasujące do filtra. Wymagane dla monorepów, w których najnowsze wydanie należy do innej aplikacji. Filtr zaawansowany @@ -613,5 +619,10 @@ Wpisz filtr, aby zobaczyć pasujące zasoby. Żadne ostatnie wydanie nie zawiera pasujących zasobów. Włącz powrót do starszych wydań lub dostosuj regex. Nie można załadować podglądu. Sprawdź połączenie i spróbuj ponownie. - Pasuje w %1$s · %2$d zasób(y) + + Pasuje w %1$s · %2$d zasób + Pasuje w %1$s · %2$d zasoby + Pasuje w %1$s · %2$d zasobów + Pasuje w %1$s · %2$d zasobów + \ 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 cc909f013..450214f20 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -601,7 +601,13 @@ Будут установлены только ассеты, соответствующие этому шаблону (regex). Полезно для монорепо с несколькими приложениями. Недопустимое регулярное выражение Ни один ассет не соответствует фильтру - Показано %1$d из %2$d ассетов + В этом релизе нет устанавливаемых ассетов + + Показан %1$d из %2$d ассета + Показано %1$d из %2$d ассетов + Показано %1$d из %2$d ассетов + Показано %1$d из %2$d ассетов + Возврат к старым релизам Просматривать предыдущие релизы, пока не найдётся соответствующий фильтру. Необходимо для монорепо, где последний релиз принадлежит другому приложению. Расширенный фильтр @@ -613,5 +619,10 @@ Введите фильтр, чтобы увидеть подходящие ассеты. В последних релизах нет подходящих ассетов. Включите возврат к старым релизам или измените regex. Не удалось загрузить предпросмотр. Проверьте подключение и попробуйте снова. - Совпадение в %1$s · %2$d ассет(ов) + + Совпадение в %1$s · %2$d ассет + Совпадение в %1$s · %2$d ассета + Совпадение в %1$s · %2$d ассетов + Совпадение в %1$s · %2$d ассетов + \ 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 85a0686dd..5f8e55b20 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -635,7 +635,11 @@ Yalnızca bu desene (regex) uyan varlıklar yüklenir. Birden fazla uygulama içeren monorepolar için kullanışlıdır. Geçersiz regex Bu filtreye uyan varlık yok - %2$d varlığın %1$d tanesi gösteriliyor + Bu sürümde yüklenebilir varlık yok + + %2$d varlığın %1$d tanesi gösteriliyor + %2$d varlığın %1$d tanesi gösteriliyor + Eski sürümlere geri dön Filtreye uyan biri bulunana kadar önceki sürümlere göz at. Son sürümün başka bir uygulamaya ait olduğu monorepolar için gereklidir. Gelişmiş filtre @@ -647,5 +651,8 @@ Eşleşen varlıkları önizlemek için bir filtre yazın. Son sürümlerde eşleşen varlık yok. Eski sürümlere geri dönmeyi etkinleştirin veya regex\'i ayarlayın. Önizleme yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin. - %1$s sürümünde eşleşti · %2$d varlık + + %1$s sürümünde eşleşti · %2$d varlık + %1$s sürümünde eşleşti · %2$d varlık + 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 a1a707644..a74997b86 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 @@ -601,7 +601,10 @@ 仅安装与此模式 (regex) 匹配的资产。适用于包含多个应用的单一仓库。 无效的正则表达式 没有匹配此过滤器的资产 - 显示 %2$d 个资产中的 %1$d 个 + 此版本中没有可安装的资产 + + 显示 %2$d 个资产中的 %1$d 个 + 回退到旧版本 遍历过去的版本,直到找到匹配过滤器的版本。最新版本属于其他应用的单一仓库需要此功能。 高级过滤器 @@ -613,5 +616,7 @@ 输入过滤器以预览匹配的资产。 最近的版本中没有匹配的资产。请启用回退到旧版本或调整正则表达式。 无法加载预览。请检查您的连接并重试。 - 在 %1$s 中匹配 · %2$d 个资产 + + 在 %1$s 中匹配 · %2$d 个资产 + \ 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 ee8dc9d50..98afd6b49 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -645,7 +645,11 @@ Only assets matching this pattern (regex) will be installed. Useful for monorepos that ship multiple apps. Invalid regex No assets match this filter - Showing %1$d of %2$d assets + No installable assets in this release + + Showing %1$d of %2$d asset + Showing %1$d of %2$d assets + Fall back to older releases Walk back through past releases until one matches the filter. Required for monorepos where the latest release belongs to a sibling app. Advanced filter @@ -657,5 +661,8 @@ Type a filter to preview matching assets. No releases in the recent window contain matching assets. Try enabling fallback to older releases or adjusting the regex. Could not load preview. Check your connection and try again. - Matched in %1$s · %2$d asset(s) + + Matched in %1$s · %2$d asset + Matched in %1$s · %2$d assets + 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 856b7b59f..0ad2dfc90 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 @@ -201,7 +201,7 @@ class AppsRepositoryImpl( val apps = appsRepository.getAllInstalledApps().first() val exported = ExportedAppList( - version = 1, + version = 2, exportedAt = Clock.System.now().toEpochMilliseconds(), apps = apps.map { app -> @@ -210,6 +210,8 @@ class AppsRepositoryImpl( repoOwner = app.repoOwner, repoName = app.repoName, repoUrl = app.repoUrl, + assetFilterRegex = app.assetFilterRegex, + fallbackToOlderReleases = app.fallbackToOlderReleases, ) }, ) @@ -254,7 +256,12 @@ class AppsRepositoryImpl( signingFingerprint = systemInfo?.signingFingerprint, ) - linkAppToRepo(deviceApp, repoInfo) + linkAppToRepo( + deviceApp = deviceApp, + repoInfo = repoInfo, + assetFilterRegex = exportedApp.assetFilterRegex, + fallbackToOlderReleases = exportedApp.fallbackToOlderReleases, + ) imported++ } catch (e: Exception) { logger.error("Failed to import ${exportedApp.repoOwner}/${exportedApp.repoName}: ${e.message}") 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 1a00a88e8..6c0c7c71a 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 @@ -580,14 +580,13 @@ class AppsViewModel( viewModelScope.launch { _state.update { it.copy(advancedSavingFilter = true) } try { + // `setAssetFilter` persists and then re-checks internally, + // so the UI badge is refreshed without a second round-trip. installedAppsRepository.setAssetFilter( packageName = app.packageName, regex = draftFilter.takeIf { it.isNotEmpty() }, fallbackToOlderReleases = draftFallback, ) - // Re-run the update check immediately so the UI badge updates - // without waiting for the next periodic worker run. - installedAppsRepository.checkForUpdates(app.packageName) _state.update { it.copy( advancedSettingsApp = null, @@ -1097,6 +1096,11 @@ class AppsViewModel( * `Ente Auth` → `auth`) * 3. [AssetFilter.suggestFromAssetName] on the first asset * + * Every candidate is routed through [Regex.escape] before validation + * so metacharacters in package names or display words (think + * `My App (Beta)` → `(beta)`) are treated literally and never break + * regex compilation. + * * Returns the first non-blank candidate that actually matches at least * one of the available assets — otherwise null, which leaves the field * empty so we don't pre-fill something useless. @@ -1109,29 +1113,29 @@ class AppsViewModel( val state = _state.value val assets = state.linkInstallableAssets - fun candidateMatches(candidate: String): Boolean { + fun tryCandidate(rawToken: String): String? { + if (rawToken.length < 3) return null + val escaped = Regex.escape(rawToken) val regex = - runCatching { Regex(candidate, RegexOption.IGNORE_CASE) }.getOrNull() - ?: return false - return assets.any { regex.containsMatchIn(it.name) } + runCatching { Regex(escaped, RegexOption.IGNORE_CASE) }.getOrNull() + ?: return null + return if (assets.any { regex.containsMatchIn(it.name) }) escaped else null } // 1. Last package segment (commonly the most distinctive token). val packageTail = packageName.substringAfterLast('.').lowercase() - if (packageTail.length >= 3 && candidateMatches(packageTail)) { - return packageTail - } + tryCandidate(packageTail)?.let { return it } // 2. Significant words from the display name. deviceAppName .split(' ', '-', '_') .map { it.lowercase().trim() } - .filter { it.length >= 3 } .forEach { token -> - if (candidateMatches(token)) return token + tryCandidate(token)?.let { return it } } - // 3. Heuristic on the first asset name. + // 3. Heuristic on the first asset name (already escaped + anchored + // by AssetFilter.suggestFromAssetName). return firstAssetName?.let { AssetFilter.suggestFromAssetName(it) } } 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 a2e4af605..a7140464e 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 @@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.AppsAction import zed.rainxch.apps.presentation.AppsState @@ -307,8 +308,9 @@ private fun PreviewSection( if (matchedTag != null) { Text( text = - stringResource( - Res.string.advanced_preview_release, + pluralStringResource( + Res.plurals.advanced_preview_release, + matchedAssets.size, matchedTag, matchedAssets.size, ), diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt index dcc1eddbc..562607f23 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.AppsAction import zed.rainxch.apps.presentation.AppsState @@ -450,8 +451,13 @@ private fun PickAssetStep( visibleAssets.isEmpty() && filterValue.isNotBlank() -> stringResource(Res.string.asset_filter_no_match) filterValue.isNotBlank() -> - stringResource( - Res.string.asset_filter_visible_count, + // Pass the total asset count as the plural + // quantity so Polish/Russian inflection picks + // the right form based on the *collection* + // size, and supply both counts as format args. + pluralStringResource( + Res.plurals.asset_filter_visible_count, + allAssets.size, visibleAssets.size, allAssets.size, ) @@ -568,6 +574,20 @@ private fun PickAssetStep( if (visibleAssets.isEmpty()) { item { + // Three distinct empty states: + // - No installable assets at all in the repo release + // (defensive: validateAndLinkRepo short-circuits + // this today, but guard in case flows change) + // - Filter regex is invalid (shown in error color) + // - Filter is valid but matched nothing + val (message, isError) = when { + allAssets.isEmpty() -> + stringResource(Res.string.asset_none_available) to false + filterError != null -> + stringResource(Res.string.asset_filter_invalid) to true + else -> + stringResource(Res.string.asset_filter_no_match) to false + } Box( modifier = Modifier .fillMaxWidth() @@ -575,9 +595,14 @@ private fun PickAssetStep( contentAlignment = Alignment.Center, ) { Text( - text = stringResource(Res.string.asset_filter_no_match), + text = message, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = + if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } }