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 00000000..27f4f0fd --- /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/initDatabase.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt index d6bda865..576d9a0c 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/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 00000000..6446d522 --- /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/AppDatabase.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt index 8376fd31..81d1a471 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 653031ee..348d5a3c 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,9 +74,52 @@ 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, 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/local/db/entities/InstalledAppEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt index a12e5a6c..68d8969a 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, ) 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 88c234f0..818cb903 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 6da9595f..20080c06 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 @@ -19,11 +20,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 +36,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 +91,179 @@ 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: 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() + } + } - 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()) { + // 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 + } + + // 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() + val resolved = + resolveTrackedRelease( + releases = releases, + filter = compiledFilter, + fallbackToOlderReleases = app.fallbackToOlderReleases, + ) + + if (resolved == null) { Logger.d { - "Update check for ${app.appName}: " + - "installedTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " + - "installedCode=${app.installedVersionCode}, " + - "isUpdate=$isUpdateAvailable" + "No matching release found for ${app.appName} in window of ${releases.size}; " + + "filter=${app.assetFilterRegex}, fallback=${app.fallbackToOlderReleases}" } + // 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 + } - 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 (matchedRelease, primaryAsset) = resolved + val normalizedInstalledTag = normalizeVersion(app.installedVersion) + val normalizedLatestTag = normalizeVersion(matchedRelease.tagName) - return isUpdateAvailable - } else { - installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) + 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 +355,79 @@ 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, + ) + + // 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( + 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/ExportedApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt index a92d1a2f..76f94a94 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/model/InstalledApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt index 2f1d65cd..673c03dc 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 85b9ebff..483fb8cf 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/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 00000000..966b3efd --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt @@ -0,0 +1,74 @@ +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 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 → ^\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 + * 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. + 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 843a8446..7e73c50e 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,31 @@ تعديلات تعديلات إصدارات تجريبية + + + عامل تصفية الأصول + مثال: ente-auth + سيتم تثبيت الأصول المطابقة لهذا النمط (regex) فقط. مفيد للمستودعات التي تحتوي على عدة تطبيقات. + تعبير عادي غير صالح + لا توجد أصول مطابقة لهذا الفلتر + لا توجد أصول قابلة للتثبيت في هذا الإصدار + + عرض %1$d من %2$d أصل + عرض %1$d من %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 9ecb5171..37459469 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,31 @@ টুইকস টুইকস প্রি-রিলিজ + + + অ্যাসেট ফিল্টার + যেমন: ente-auth + শুধুমাত্র এই প্যাটার্নের (regex) সাথে মেলে এমন অ্যাসেট ইনস্টল করা হবে। মনোরিপোর জন্য উপযোগী। + অবৈধ regex + এই ফিল্টারের সাথে মেলে এমন কোনো অ্যাসেট নেই + এই রিলিজে কোনো ইনস্টলযোগ্য অ্যাসেট নেই + + %2$d-এর মধ্যে %1$d দেখানো হচ্ছে + %2$d-এর মধ্যে %1$d দেখানো হচ্ছে + + পুরোনো রিলিজে ফিরে যান + ফিল্টারের সাথে মেলে এমন একটি না পাওয়া পর্যন্ত পূর্ববর্তী রিলিজগুলো দেখুন। মনোরিপোর জন্য প্রয়োজনীয় যেখানে সর্বশেষ রিলিজ অন্য অ্যাপের। + উন্নত ফিল্টার + এই অ্যাপটি কীভাবে রিপোজিটরিতে মিলবে তা কনফিগার করুন। যখন রিপো একাধিক অ্যাপ পাঠায় তখন এই সেটিংস ব্যবহার করুন। + উন্নত ফিল্টার খুলুন + সংরক্ষণ + প্রিভিউ + প্রিভিউ রিফ্রেশ + মেলে এমন অ্যাসেট প্রিভিউ করতে একটি ফিল্টার টাইপ করুন। + সাম্প্রতিক উইন্ডোতে কোনো রিলিজে মিলে যাওয়া অ্যাসেট নেই। পুরোনো রিলিজে ফিরে যাওয়া সক্রিয় করুন বা regex সমন্বয় করুন। + প্রিভিউ লোড করা যায়নি। আপনার সংযোগ পরীক্ষা করে আবার চেষ্টা করুন। + + %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 34cf5e9b..127bd7d7 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,31 @@ 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 + 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 + 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 + 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 3af132c1..499151e6 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,31 @@ 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 + 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é + 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 + 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 23ce87ce..bed065b2 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,31 @@ ट्वीक्स ट्वीक्स प्री-रिलीज़ + + + एसेट फ़िल्टर + उदा. ente-auth + केवल इस पैटर्न (regex) से मेल खाने वाले एसेट इंस्टॉल किए जाएंगे। मोनोरिपो के लिए उपयोगी। + अमान्य regex + कोई भी एसेट इस फ़िल्टर से मेल नहीं खाता + इस रिलीज़ में कोई इंस्टॉल करने योग्य एसेट नहीं है + + %2$d में से %1$d एसेट दिखाया जा रहा है + %2$d में से %1$d एसेट दिखाए जा रहे हैं + + पुराने रिलीज़ पर वापस जाएँ + फ़िल्टर से मेल खाने वाला कोई न मिलने तक पिछले रिलीज़ खंगालें। मोनोरिपो के लिए आवश्यक जहाँ नवीनतम रिलीज़ किसी अन्य ऐप का है। + उन्नत फ़िल्टर + कॉन्फ़िगर करें कि यह ऐप रिपॉज़िटरी में कैसे मेल खाता है। जब रिपो कई ऐप्स पैक करता है तब इन सेटिंग्स का उपयोग करें। + उन्नत फ़िल्टर खोलें + सहेजें + पूर्वावलोकन + पूर्वावलोकन रीफ़्रेश करें + मेल खाने वाले एसेट देखने के लिए फ़िल्टर टाइप करें। + हाल के रिलीज़ में कोई मेल खाने वाला एसेट नहीं मिला। पुराने रिलीज़ पर फ़ॉलबैक चालू करें या regex समायोजित करें। + पूर्वावलोकन लोड नहीं हो सका। अपना कनेक्शन जाँचें और पुनः प्रयास करें। + + %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 fc49c3a1..0f656014 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,31 @@ 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 + 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 + 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 + 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 d7615153..9cf42122 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,29 @@ 調整 調整 プレリリース + + + アセットフィルター + 例: 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 2d99333c..9d48b80b 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,29 @@ 조정 조정 프리릴리스 + + + 에셋 필터 + 예: 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 756f3572..70ab1c95 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,35 @@ 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 + 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 + 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 + 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 33683cd1..450214f2 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,35 @@ Настройки Настройки Пре-релизы + + + Фильтр ассетов + напр. ente-auth + Будут установлены только ассеты, соответствующие этому шаблону (regex). Полезно для монорепо с несколькими приложениями. + Недопустимое регулярное выражение + Ни один ассет не соответствует фильтру + В этом релизе нет устанавливаемых ассетов + + Показан %1$d из %2$d ассета + Показано %1$d из %2$d ассетов + Показано %1$d из %2$d ассетов + Показано %1$d из %2$d ассетов + + Возврат к старым релизам + Просматривать предыдущие релизы, пока не найдётся соответствующий фильтру. Необходимо для монорепо, где последний релиз принадлежит другому приложению. + Расширенный фильтр + Настройте, как это приложение определяется в репозитории. Используйте эти настройки, если репо содержит несколько приложений. + Открыть расширенный фильтр + Сохранить + Предпросмотр + Обновить предпросмотр + Введите фильтр, чтобы увидеть подходящие ассеты. + В последних релизах нет подходящих ассетов. Включите возврат к старым релизам или измените regex. + Не удалось загрузить предпросмотр. Проверьте подключение и попробуйте снова. + + Совпадение в %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 f43b397d..5f8e55b2 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,31 @@ İ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 + 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 + 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 + %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 2c008681..a74997b8 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,29 @@ 调整 调整 预发布版本 + + + 资产过滤器 + 例如: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/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 9ad81f84..98afd6b4 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -638,4 +638,31 @@ 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 + 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 + 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 + 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 f5dca9a5..0ad2dfc9 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) @@ -196,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 -> @@ -205,6 +210,8 @@ class AppsRepositoryImpl( repoOwner = app.repoOwner, repoName = app.repoName, repoUrl = app.repoUrl, + assetFilterRegex = app.assetFilterRegex, + fallbackToOlderReleases = app.fallbackToOlderReleases, ) }, ) @@ -249,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/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 609a6200..df0a5540 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/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 8923c0d0..31594c49 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 5d603d7a..9981fcab 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 69a70ceb..b7425eeb 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 cb6b167a..6c0c7c71 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,166 @@ 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 { + // `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, + ) + _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 +1062,83 @@ 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 + * + * 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. + */ + private fun suggestFilterForLink( + deviceAppName: String, + packageName: String, + firstAssetName: String?, + ): String? { + val state = _state.value + val assets = state.linkInstallableAssets + + fun tryCandidate(rawToken: String): String? { + if (rawToken.length < 3) return null + val escaped = Regex.escape(rawToken) + val regex = + 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() + tryCandidate(packageTail)?.let { return it } + + // 2. Significant words from the display name. + deviceAppName + .split(' ', '-', '_') + .map { it.lowercase().trim() } + .forEach { token -> + tryCandidate(token)?.let { return it } + } + + // 3. Heuristic on the first asset name (already escaped + anchored + // by AssetFilter.suggestFromAssetName). + return firstAssetName?.let { AssetFilter.suggestFromAssetName(it) } + } + private fun validateAndLinkRepo() { val selectedApp = _state.value.selectedDeviceApp ?: return val url = _state.value.repoUrl.trim() @@ -947,12 +1244,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 +1330,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 +1387,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 00000000..a7140464 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt @@ -0,0 +1,367 @@ +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.pluralStringResource +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 = + pluralStringResource( + Res.plurals.advanced_preview_release, + matchedAssets.size, + 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 60696303..562607f2 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 @@ -45,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 @@ -98,11 +101,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 +376,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 +426,95 @@ 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() -> + // 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, + ) + 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 +571,42 @@ private fun PickAssetStep( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), ) } + + 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() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = + if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } } if (validationStatus != null) { 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 7006b60f..138e5163 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, ) 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 be1b0024..c988ee13 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, )