From 1f01c554e6a284e57dfa32bb8de48d121c8eab50 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 20:44:32 +0500 Subject: [PATCH 01/46] E1: external-import domain interfaces and models --- .../repository/ExternalImportRepository.kt | 45 +++++++++++++++++++ .../domain/system/ExternalAppCandidate.kt | 12 +++++ .../core/domain/system/ExternalAppScanner.kt | 17 +++++++ .../core/domain/system/ExternalLinkState.kt | 8 ++++ .../core/domain/system/InstallerKind.kt | 15 +++++++ .../core/domain/system/ManifestHint.kt | 15 +++++++ .../core/domain/system/RepoMatchResult.kt | 21 +++++++++ .../core/domain/system/RepoMatchSource.kt | 8 ++++ .../rainxch/core/domain/system/ScanResult.kt | 16 +++++++ 9 files changed, 157 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppCandidate.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppScanner.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalLinkState.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/InstallerKind.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ManifestHint.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchSource.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt new file mode 100644 index 000000000..af9b6584d --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt @@ -0,0 +1,45 @@ +package zed.rainxch.core.domain.repository + +import kotlinx.coroutines.flow.Flow +import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.ImportSummary +import zed.rainxch.core.domain.system.RepoMatchResult +import zed.rainxch.core.domain.system.ScanResult + +interface ExternalImportRepository { + fun pendingCandidatesFlow(): Flow> + + fun pendingCandidateCountFlow(): Flow + + suspend fun scheduleInitialScanIfNeeded() + + suspend fun runFullScan(): ScanResult + + suspend fun runDeltaScan(changedPackageNames: Set): ScanResult + + suspend fun resolveMatches(candidates: List): List + + suspend fun importAutoMatched(matches: List): ImportSummary + + suspend fun linkManually( + packageName: String, + owner: String, + repo: String, + source: String, + ): Result + + suspend fun skipPackage( + packageName: String, + neverAsk: Boolean = false, + ) + + suspend fun unlink(packageName: String) + + suspend fun rescanSinglePackage(packageName: String): RepoMatchResult? + + suspend fun syncSigningFingerprintSeed() + + suspend fun pruneExpiredSkips() + + suspend fun isPermissionGranted(): Boolean +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppCandidate.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppCandidate.kt new file mode 100644 index 000000000..30d844167 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppCandidate.kt @@ -0,0 +1,12 @@ +package zed.rainxch.core.domain.system + +data class ExternalAppCandidate( + val packageName: String, + val appLabel: String, + val versionName: String?, + val versionCode: Long, + val signingFingerprint: String?, + val installerKind: InstallerKind, + val manifestHint: ManifestHint?, + val firstSeenAt: Long, +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppScanner.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppScanner.kt new file mode 100644 index 000000000..cc2c0928a --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalAppScanner.kt @@ -0,0 +1,17 @@ +package zed.rainxch.core.domain.system + +interface ExternalAppScanner { + suspend fun isPermissionGranted(): Boolean + + suspend fun visiblePackageCountEstimate(): VisiblePackageEstimate + + suspend fun snapshot(): List + + suspend fun snapshotSingle(packageName: String): ExternalAppCandidate? +} + +data class VisiblePackageEstimate( + val visibleCount: Int, + val invisibleEstimate: Int, + val permissionGranted: Boolean, +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalLinkState.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalLinkState.kt new file mode 100644 index 000000000..bd7297342 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalLinkState.kt @@ -0,0 +1,8 @@ +package zed.rainxch.core.domain.system + +enum class ExternalLinkState { + PENDING_REVIEW, + MATCHED, + SKIPPED, + NEVER_ASK, +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/InstallerKind.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/InstallerKind.kt new file mode 100644 index 000000000..04a1e6cc3 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/InstallerKind.kt @@ -0,0 +1,15 @@ +package zed.rainxch.core.domain.system + +enum class InstallerKind { + STORE_OBTAINIUM, + STORE_FDROID, + STORE_PLAY, + STORE_AURORA, + STORE_GALAXY, + STORE_OEM_OTHER, + BROWSER, + SYSTEM, + SIDELOAD, + GITHUB_STORE_SELF, + UNKNOWN, +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ManifestHint.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ManifestHint.kt new file mode 100644 index 000000000..9879a188a --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ManifestHint.kt @@ -0,0 +1,15 @@ +package zed.rainxch.core.domain.system + +enum class ManifestHintSource { + META_GITHUB_REPO, + META_FDROID_SOURCE_CODE, + META_UPSTREAM_URL, + META_APP_REPO_URL, +} + +data class ManifestHint( + val owner: String, + val repo: String, + val source: ManifestHintSource, + val confidence: Double, +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt new file mode 100644 index 000000000..4269cf2a2 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt @@ -0,0 +1,21 @@ +package zed.rainxch.core.domain.system + +data class RepoMatchSuggestion( + val owner: String, + val repo: String, + val confidence: Double, + val source: RepoMatchSource, + val stars: Int? = null, + val description: String? = null, +) + +data class RepoMatchResult( + val packageName: String, + val suggestions: List, +) { + val topConfidence: Double + get() = suggestions.maxOfOrNull { it.confidence } ?: 0.0 + + val topSuggestion: RepoMatchSuggestion? + get() = suggestions.maxByOrNull { it.confidence } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchSource.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchSource.kt new file mode 100644 index 000000000..b6457ecd5 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchSource.kt @@ -0,0 +1,8 @@ +package zed.rainxch.core.domain.system + +enum class RepoMatchSource { + MANIFEST, + SEARCH, + FINGERPRINT, + MANUAL, +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt new file mode 100644 index 000000000..61cfa2844 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt @@ -0,0 +1,16 @@ +package zed.rainxch.core.domain.system + +data class ScanResult( + val totalCandidates: Int, + val newCandidates: Int, + val autoLinked: Int, + val pendingReview: Int, + val durationMillis: Long, + val permissionGranted: Boolean, +) + +data class ImportSummary( + val attempted: Int, + val linked: Int, + val failed: Int, +) From 3056e220959b111c7f37319fb9b8b95bb5a17f4b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 20:45:30 +0500 Subject: [PATCH 02/46] data: implement persistent storage paths and migration for desktop - Introduce `DesktopAppDataPaths` to manage OS-specific application data directories (macOS, Windows, Linux). - Migrate database and DataStore files from the system temporary directory to the new persistent app data location. - Ensure all SQLite sidecar files (`.db-shm`, `.db-wal`) are included in the migration to preserve WAL transactions. - Update `initDatabase` and `createDataStore` to utilize the new persistent storage paths. - Add `roadmap/` to `.gitignore`. --- .claude/settings.local.json | 4 +- .gitignore | 4 +- .../core/data/local/DesktopAppDataPaths.kt | 44 +++++++++++++++++++ .../data/local/data_store/createDataStore.kt | 5 ++- .../core/data/local/db/initDatabase.kt | 9 +++- 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/DesktopAppDataPaths.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 031e7c219..d863252fe 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,9 @@ "WebFetch(domain:youtrack.jetbrains.com)", "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:blog.jetbrains.com)", - "Bash(rtk grep *)" + "Bash(rtk grep *)", + "Bash(git checkout *)", + "Bash(rtk proxy *)" ] } } diff --git a/.gitignore b/.gitignore index f309553c9..95ea5e03c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ google-services.json composeApp/release/baselineProfiles/ -composeApp/kotzilla.json \ No newline at end of file +composeApp/kotzilla.json + +roadmap/ \ No newline at end of file diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/DesktopAppDataPaths.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/DesktopAppDataPaths.kt new file mode 100644 index 000000000..3de01f1b1 --- /dev/null +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/DesktopAppDataPaths.kt @@ -0,0 +1,44 @@ +package zed.rainxch.core.data.local + +import java.io.File + +object DesktopAppDataPaths { + private const val APP_DIR_NAME = "GitHub-Store" + + fun appDataDir(): File { + val home = File(System.getProperty("user.home")) + val osName = System.getProperty("os.name").orEmpty().lowercase() + val dir = when { + "mac" in osName -> + File(home, "Library/Application Support/$APP_DIR_NAME") + + "win" in osName -> { + val appData = System.getenv("APPDATA")?.let(::File) + ?: System.getenv("LOCALAPPDATA")?.let(::File) + ?: File(home, "AppData/Roaming") + File(appData, APP_DIR_NAME) + } + + else -> { + val dataHome = System.getenv("XDG_DATA_HOME")?.let(::File) + ?: File(home, ".local/share") + File(dataHome, APP_DIR_NAME) + } + } + if (!dir.exists()) dir.mkdirs() + return dir + } + + fun migrateFromTmpIfNeeded(filename: String): Boolean { + val newFile = File(appDataDir(), filename) + if (newFile.exists()) return false + val legacyFile = File(System.getProperty("java.io.tmpdir"), filename) + if (!legacyFile.exists()) return false + return try { + legacyFile.copyTo(newFile, overwrite = false) + true + } catch (_: Exception) { + false + } + } +} diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/data_store/createDataStore.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/data_store/createDataStore.kt index 27c32b61b..9f847b717 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/data_store/createDataStore.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/data_store/createDataStore.kt @@ -2,12 +2,13 @@ package zed.rainxch.core.data.local.data_store import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import zed.rainxch.core.data.local.DesktopAppDataPaths import java.io.File fun createDataStore(): DataStore = createDataStore( producePath = { - val file = File(System.getProperty("java.io.tmpdir"), dataStoreFileName) - file.absolutePath + DesktopAppDataPaths.migrateFromTmpIfNeeded(dataStoreFileName) + File(DesktopAppDataPaths.appDataDir(), dataStoreFileName).absolutePath }, ) diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt index b6713bcfd..109565888 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt @@ -3,10 +3,17 @@ package zed.rainxch.core.data.local.db import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import kotlinx.coroutines.Dispatchers +import zed.rainxch.core.data.local.DesktopAppDataPaths import java.io.File fun initDatabase(): AppDatabase { - val dbFile = File(System.getProperty("java.io.tmpdir"), "github_store.db") + // SQLite WAL mode keeps two side files alongside the .db; migrate all three + // so an upgrade preserves any uncommitted transactions in the WAL. + DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db") + DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db-shm") + DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db-wal") + + val dbFile = File(DesktopAppDataPaths.appDataDir(), "github_store.db") return Room .databaseBuilder( name = dbFile.absolutePath, From cacfd2b15e16ead12cf862e80ff9792f2177223a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 20:46:02 +0500 Subject: [PATCH 03/46] E1: room entities, daos, and migration 14_15 for external links --- .../15.json | 793 ++++++++++++++++++ .../core/data/local/db/initDatabase.kt | 2 + .../local/db/migrations/MIGRATION_14_15.kt | 50 ++ .../rainxch/core/data/local/db/AppDatabase.kt | 10 +- .../core/data/local/db/dao/ExternalLinkDao.kt | 38 + .../local/db/dao/SigningFingerprintDao.kt | 22 + .../local/db/entities/ExternalLinkEntity.kt | 23 + .../db/entities/SigningFingerprintEntity.kt | 17 + 8 files changed, 954 insertions(+), 1 deletion(-) create mode 100644 core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/15.json create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_14_15.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/SigningFingerprintDao.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/ExternalLinkEntity.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/SigningFingerprintEntity.kt diff --git a/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/15.json b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/15.json new file mode 100644 index 000000000..d4029bab7 --- /dev/null +++ b/core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/15.json @@ -0,0 +1,793 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "e53aff01af051a55781bb6228c5f306f", + "entities": [ + { + "tableName": "installed_apps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `repoName` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoOwnerAvatarUrl` TEXT NOT NULL, `repoDescription` TEXT, `primaryLanguage` TEXT, `repoUrl` TEXT NOT NULL, `installedVersion` TEXT NOT NULL, `installedAssetName` TEXT, `installedAssetUrl` TEXT, `latestVersion` TEXT, `latestAssetName` TEXT, `latestAssetUrl` TEXT, `latestAssetSize` INTEGER, `appName` TEXT NOT NULL, `installSource` TEXT NOT NULL, `signingFingerprint` TEXT, `installedAt` INTEGER NOT NULL, `lastCheckedAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `isUpdateAvailable` INTEGER NOT NULL, `updateCheckEnabled` INTEGER NOT NULL, `releaseNotes` TEXT, `systemArchitecture` TEXT NOT NULL, `fileExtension` TEXT NOT NULL, `isPendingInstall` INTEGER NOT NULL, `installedVersionName` TEXT, `installedVersionCode` INTEGER NOT NULL, `latestVersionName` TEXT, `latestVersionCode` INTEGER, `latestReleasePublishedAt` TEXT, `includePreReleases` INTEGER NOT NULL, `assetFilterRegex` TEXT, `fallbackToOlderReleases` INTEGER NOT NULL DEFAULT 0, `preferredAssetVariant` TEXT, `preferredVariantStale` INTEGER NOT NULL DEFAULT 0, `preferredAssetTokens` TEXT, `assetGlobPattern` TEXT, `pickedAssetIndex` INTEGER, `pickedAssetSiblingCount` INTEGER, `pendingInstallFilePath` TEXT, `pendingInstallVersion` TEXT, `pendingInstallAssetName` TEXT, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwnerAvatarUrl", + "columnName": "repoOwnerAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoDescription", + "columnName": "repoDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryLanguage", + "columnName": "primaryLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "repoUrl", + "columnName": "repoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedVersion", + "columnName": "installedVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedAssetName", + "columnName": "installedAssetName", + "affinity": "TEXT" + }, + { + "fieldPath": "installedAssetUrl", + "columnName": "installedAssetUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "latestVersion", + "columnName": "latestVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "latestAssetName", + "columnName": "latestAssetName", + "affinity": "TEXT" + }, + { + "fieldPath": "latestAssetUrl", + "columnName": "latestAssetUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "latestAssetSize", + "columnName": "latestAssetSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "appName", + "columnName": "appName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installSource", + "columnName": "installSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signingFingerprint", + "columnName": "signingFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "installedAt", + "columnName": "installedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "lastCheckedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUpdateAvailable", + "columnName": "isUpdateAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateCheckEnabled", + "columnName": "updateCheckEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "releaseNotes", + "affinity": "TEXT" + }, + { + "fieldPath": "systemArchitecture", + "columnName": "systemArchitecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileExtension", + "columnName": "fileExtension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPendingInstall", + "columnName": "isPendingInstall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedVersionName", + "columnName": "installedVersionName", + "affinity": "TEXT" + }, + { + "fieldPath": "installedVersionCode", + "columnName": "installedVersionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestVersionName", + "columnName": "latestVersionName", + "affinity": "TEXT" + }, + { + "fieldPath": "latestVersionCode", + "columnName": "latestVersionCode", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestReleasePublishedAt", + "columnName": "latestReleasePublishedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "includePreReleases", + "columnName": "includePreReleases", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assetFilterRegex", + "columnName": "assetFilterRegex", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackToOlderReleases", + "columnName": "fallbackToOlderReleases", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "preferredAssetVariant", + "columnName": "preferredAssetVariant", + "affinity": "TEXT" + }, + { + "fieldPath": "preferredVariantStale", + "columnName": "preferredVariantStale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "preferredAssetTokens", + "columnName": "preferredAssetTokens", + "affinity": "TEXT" + }, + { + "fieldPath": "assetGlobPattern", + "columnName": "assetGlobPattern", + "affinity": "TEXT" + }, + { + "fieldPath": "pickedAssetIndex", + "columnName": "pickedAssetIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "pickedAssetSiblingCount", + "columnName": "pickedAssetSiblingCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "pendingInstallFilePath", + "columnName": "pendingInstallFilePath", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingInstallVersion", + "columnName": "pendingInstallVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingInstallAssetName", + "columnName": "pendingInstallAssetName", + "affinity": "TEXT" + } + ], + "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" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `state` TEXT NOT NULL, `repoOwner` TEXT, `repoName` TEXT, `matchSource` TEXT, `matchConfidence` REAL, `signingFingerprint` TEXT, `installerKind` TEXT, `firstSeenAt` INTEGER NOT NULL, `lastReviewedAt` INTEGER NOT NULL, `skipExpiresAt` INTEGER, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT" + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT" + }, + { + "fieldPath": "matchSource", + "columnName": "matchSource", + "affinity": "TEXT" + }, + { + "fieldPath": "matchConfidence", + "columnName": "matchConfidence", + "affinity": "REAL" + }, + { + "fieldPath": "signingFingerprint", + "columnName": "signingFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "installerKind", + "columnName": "installerKind", + "affinity": "TEXT" + }, + { + "fieldPath": "firstSeenAt", + "columnName": "firstSeenAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReviewedAt", + "columnName": "lastReviewedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipExpiresAt", + "columnName": "skipExpiresAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [ + { + "name": "index_external_links_repoOwner_repoName", + "unique": false, + "columnNames": [ + "repoOwner", + "repoName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_external_links_repoOwner_repoName` ON `${TABLE_NAME}` (`repoOwner`, `repoName`)" + } + ] + }, + { + "tableName": "signing_fingerprints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fingerprint` TEXT NOT NULL, `repoOwner` TEXT NOT NULL, `repoName` TEXT NOT NULL, `source` TEXT NOT NULL, `observedAt` INTEGER NOT NULL, PRIMARY KEY(`fingerprint`))", + "fields": [ + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoOwner", + "columnName": "repoOwner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoName", + "columnName": "repoName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observedAt", + "columnName": "observedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fingerprint" + ] + }, + "indices": [ + { + "name": "index_signing_fingerprints_repoOwner_repoName", + "unique": false, + "columnNames": [ + "repoOwner", + "repoName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_signing_fingerprints_repoOwner_repoName` ON `${TABLE_NAME}` (`repoOwner`, `repoName`)" + } + ] + } + ], + "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, 'e53aff01af051a55781bb6228c5f306f')" + ] + } +} \ 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 b2a332806..78e746ed1 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 @@ -16,6 +16,7 @@ import zed.rainxch.core.data.local.db.migrations.MIGRATION_10_11 import zed.rainxch.core.data.local.db.migrations.MIGRATION_11_12 import zed.rainxch.core.data.local.db.migrations.MIGRATION_12_13 import zed.rainxch.core.data.local.db.migrations.MIGRATION_13_14 +import zed.rainxch.core.data.local.db.migrations.MIGRATION_14_15 fun initDatabase(context: Context): AppDatabase { val appContext = context.applicationContext @@ -39,5 +40,6 @@ fun initDatabase(context: Context): AppDatabase { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, + MIGRATION_14_15, ).build() } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_14_15.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_14_15.kt new file mode 100644 index 000000000..2693a64f5 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_14_15.kt @@ -0,0 +1,50 @@ +package zed.rainxch.core.data.local.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_14_15 = + object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS external_links ( + packageName TEXT NOT NULL PRIMARY KEY, + state TEXT NOT NULL, + repoOwner TEXT, + repoName TEXT, + matchSource TEXT, + matchConfidence REAL, + signingFingerprint TEXT, + installerKind TEXT, + firstSeenAt INTEGER NOT NULL, + lastReviewedAt INTEGER NOT NULL, + skipExpiresAt INTEGER + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS index_external_links_repoOwner_repoName + ON external_links (repoOwner, repoName) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS signing_fingerprints ( + fingerprint TEXT NOT NULL PRIMARY KEY, + repoOwner TEXT NOT NULL, + repoName TEXT NOT NULL, + source TEXT NOT NULL, + observedAt INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS index_signing_fingerprints_repoOwner_repoName + ON signing_fingerprints (repoOwner, repoName) + """.trimIndent(), + ) + } + } 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 38f57c584..628f94ba7 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 @@ -3,17 +3,21 @@ package zed.rainxch.core.data.local.db import androidx.room.Database import androidx.room.RoomDatabase import zed.rainxch.core.data.local.db.dao.CacheDao +import zed.rainxch.core.data.local.db.dao.ExternalLinkDao import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.SearchHistoryDao import zed.rainxch.core.data.local.db.dao.SeenRepoDao +import zed.rainxch.core.data.local.db.dao.SigningFingerprintDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.local.db.entities.CacheEntryEntity +import zed.rainxch.core.data.local.db.entities.ExternalLinkEntity import zed.rainxch.core.data.local.db.entities.FavoriteRepoEntity import zed.rainxch.core.data.local.db.entities.InstalledAppEntity import zed.rainxch.core.data.local.db.entities.SearchHistoryEntity import zed.rainxch.core.data.local.db.entities.SeenRepoEntity +import zed.rainxch.core.data.local.db.entities.SigningFingerprintEntity import zed.rainxch.core.data.local.db.entities.StarredRepositoryEntity import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity @@ -26,8 +30,10 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity CacheEntryEntity::class, SeenRepoEntity::class, SearchHistoryEntity::class, + ExternalLinkEntity::class, + SigningFingerprintEntity::class, ], - version = 14, + version = 15, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { @@ -38,4 +44,6 @@ abstract class AppDatabase : RoomDatabase() { abstract val cacheDao: CacheDao abstract val seenRepoDao: SeenRepoDao abstract val searchHistoryDao: SearchHistoryDao + abstract val externalLinkDao: ExternalLinkDao + abstract val signingFingerprintDao: SigningFingerprintDao } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt new file mode 100644 index 000000000..40a613bad --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt @@ -0,0 +1,38 @@ +package zed.rainxch.core.data.local.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import zed.rainxch.core.data.local.db.entities.ExternalLinkEntity + +@Dao +interface ExternalLinkDao { + @Query("SELECT * FROM external_links WHERE packageName = :packageName") + suspend fun get(packageName: String): ExternalLinkEntity? + + @Query("SELECT * FROM external_links") + fun observeAll(): Flow> + + @Query("SELECT * FROM external_links WHERE state = 'PENDING_REVIEW'") + fun observePendingReview(): Flow> + + @Query("SELECT COUNT(*) FROM external_links WHERE state = 'PENDING_REVIEW'") + fun observePendingReviewCount(): Flow + + @Query("SELECT packageName FROM external_links WHERE state IN ('MATCHED','NEVER_ASK')") + suspend fun getDoNotRescanPackageNames(): List + + @Query("SELECT packageName FROM external_links WHERE state = 'SKIPPED' AND skipExpiresAt > :now") + suspend fun getActiveSkippedPackageNames(now: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(link: ExternalLinkEntity) + + @Query("DELETE FROM external_links WHERE packageName = :packageName") + suspend fun deleteByPackageName(packageName: String) + + @Query("DELETE FROM external_links WHERE state = 'SKIPPED' AND skipExpiresAt < :now") + suspend fun pruneExpiredSkips(now: Long) +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/SigningFingerprintDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/SigningFingerprintDao.kt new file mode 100644 index 000000000..03c6b2d7d --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/SigningFingerprintDao.kt @@ -0,0 +1,22 @@ +package zed.rainxch.core.data.local.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import zed.rainxch.core.data.local.db.entities.SigningFingerprintEntity + +@Dao +interface SigningFingerprintDao { + @Query("SELECT * FROM signing_fingerprints WHERE fingerprint = :fingerprint") + suspend fun lookup(fingerprint: String): SigningFingerprintEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(rows: List) + + @Query("SELECT MAX(observedAt) FROM signing_fingerprints") + suspend fun lastSyncTimestamp(): Long? + + @Query("DELETE FROM signing_fingerprints WHERE source = 'user_link'") + suspend fun clearUserLinks() +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/ExternalLinkEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/ExternalLinkEntity.kt new file mode 100644 index 000000000..41582e326 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/ExternalLinkEntity.kt @@ -0,0 +1,23 @@ +package zed.rainxch.core.data.local.db.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "external_links", + indices = [Index(value = ["repoOwner", "repoName"])], +) +data class ExternalLinkEntity( + @PrimaryKey val packageName: String, + val state: String, + val repoOwner: String?, + val repoName: String?, + val matchSource: String?, + val matchConfidence: Double?, + val signingFingerprint: String?, + val installerKind: String?, + val firstSeenAt: Long, + val lastReviewedAt: Long, + val skipExpiresAt: Long?, +) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/SigningFingerprintEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/SigningFingerprintEntity.kt new file mode 100644 index 000000000..d9e1b8f45 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/SigningFingerprintEntity.kt @@ -0,0 +1,17 @@ +package zed.rainxch.core.data.local.db.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "signing_fingerprints", + indices = [Index(value = ["repoOwner", "repoName"])], +) +data class SigningFingerprintEntity( + @PrimaryKey val fingerprint: String, + val repoOwner: String, + val repoName: String, + val source: String, + val observedAt: Long, +) From ad2695b2050620f5e5889c58db3c123d97a9fd5f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 20:51:06 +0500 Subject: [PATCH 04/46] E1: android external app scanner with manifest and installer classification --- .../core/data/di/PlatformModule.android.kt | 21 +++ .../external/AndroidExternalAppScanner.kt | 145 ++++++++++++++++++ .../external/InstallerSourceClassifier.kt | 109 +++++++++++++ .../external/ManifestHintExtractor.kt | 92 +++++++++++ .../external/SigningFingerprintComputer.kt | 65 ++++++++ .../core/data/di/PlatformModule.jvm.kt | 6 + .../external/DesktopExternalAppScanner.kt | 20 +++ 7 files changed, 458 insertions(+) create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt create mode 100644 core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/external/DesktopExternalAppScanner.kt diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt index 5abdc9f29..343282c88 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt @@ -20,6 +20,9 @@ import zed.rainxch.core.data.services.AndroidUpdateScheduleManager import zed.rainxch.core.data.services.DownloadNotificationObserver import zed.rainxch.core.data.services.FileLocationsProvider import zed.rainxch.core.data.services.LocalizationManager +import zed.rainxch.core.data.services.external.AndroidExternalAppScanner +import zed.rainxch.core.data.services.external.InstallerSourceClassifier +import zed.rainxch.core.data.services.external.ManifestHintExtractor import zed.rainxch.core.data.services.shizuku.AndroidInstallerStatusProvider import zed.rainxch.core.data.services.shizuku.ShizukuInstallerWrapper import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager @@ -30,6 +33,7 @@ import zed.rainxch.core.data.utils.AndroidShareManager import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.system.DownloadOrchestrator import zed.rainxch.core.domain.system.DownloadProgressNotifier +import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.PackageMonitor @@ -108,6 +112,23 @@ actual val corePlatformModule = AndroidPackageMonitor(androidContext()) } + single { ManifestHintExtractor() } + + single { + InstallerSourceClassifier( + packageManager = androidContext().packageManager, + selfPackageName = androidContext().packageName, + ) + } + + single { + AndroidExternalAppScanner( + context = androidContext(), + manifestHintExtractor = get(), + installerSourceClassifier = get(), + ) + } + single { AndroidLocalizationManager() } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt new file mode 100644 index 000000000..eb55cee2e --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt @@ -0,0 +1,145 @@ +package zed.rainxch.core.data.services.external + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.ExternalAppScanner +import zed.rainxch.core.domain.system.InstallerKind +import zed.rainxch.core.domain.system.VisiblePackageEstimate + +class AndroidExternalAppScanner( + context: Context, + private val manifestHintExtractor: ManifestHintExtractor, + private val installerSourceClassifier: InstallerSourceClassifier, +) : ExternalAppScanner { + private val appContext = context.applicationContext + private val packageManager: PackageManager = appContext.packageManager + private val selfPackageName: String = appContext.packageName + + override suspend fun isPermissionGranted(): Boolean = + withContext(Dispatchers.IO) { + // Android 11+: getInstalledPackages without QUERY_ALL_PACKAGES returns + // only the self-visible subset. We treat "saw something other than self + // and the declared packages" as a proxy for grant. + // On API < 30 the permission is not enforced — always granted. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return@withContext true + val pkgs = listInstalledPackages(includeMeta = false) + // Heuristic: a typical device has 50+ visible packages. With QUERY_ALL_PACKAGES + // denied + our block, we usually see ~5-10. Treat >= 30 visible as granted. + pkgs.size >= GRANT_THRESHOLD + } + + override suspend fun visiblePackageCountEstimate(): VisiblePackageEstimate = + withContext(Dispatchers.IO) { + val granted = isPermissionGranted() + val visible = listInstalledPackages(includeMeta = false).size + val invisible = + if (granted) { + 0 + } else { + // soft "we probably can't see ~150 more" estimate; the scanner UI + // marks this as approximate. + INVISIBLE_GUESS + } + VisiblePackageEstimate( + visibleCount = visible, + invisibleEstimate = invisible, + permissionGranted = granted, + ) + } + + override suspend fun snapshot(): List = + withContext(Dispatchers.IO) { + val now = nowMillis() + listInstalledPackages(includeMeta = true) + .asSequence() + .filter { it.packageName != selfPackageName } + .mapNotNull { pkgInfo -> toCandidate(pkgInfo, now) } + .filterNot { it.installerKind == InstallerKind.SYSTEM } + .filterNot { it.installerKind == InstallerKind.STORE_PLAY } + .filterNot { it.installerKind == InstallerKind.STORE_AURORA } + .filterNot { it.installerKind == InstallerKind.STORE_GALAXY } + .filterNot { it.installerKind == InstallerKind.STORE_OEM_OTHER } + .toList() + } + + override suspend fun snapshotSingle(packageName: String): ExternalAppCandidate? = + withContext(Dispatchers.IO) { + val info = loadSinglePackage(packageName) ?: return@withContext null + toCandidate(info, nowMillis()) + } + + private fun listInstalledPackages(includeMeta: Boolean): List { + val baseFlags = + if (includeMeta) PackageManager.GET_META_DATA.toLong() else 0L + // We deliberately do NOT request signing certificates here — we compute + // the fingerprint per-package on demand to keep the bulk listing cheap. + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(baseFlags)) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(baseFlags.toInt()) + } + }.getOrDefault(emptyList()) + } + + private fun loadSinglePackage(packageName: String): PackageInfo? = + runCatching { + val flags = PackageManager.GET_META_DATA.toLong() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags)) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, flags.toInt()) + } + }.getOrNull() + + private fun toCandidate( + pkgInfo: PackageInfo, + firstSeenAt: Long, + ): ExternalAppCandidate? { + val appInfo = pkgInfo.applicationInfo ?: return null + val packageName = pkgInfo.packageName + val label = + runCatching { appInfo.loadLabel(packageManager).toString() } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: packageName + + val installerKind = installerSourceClassifier.classify(packageName, appInfo) + val manifestHint = manifestHintExtractor.extract(appInfo.metaData) + val versionCode = longVersionCode(pkgInfo) + val fingerprint = SigningFingerprintComputer.compute(packageManager, packageName) + + return ExternalAppCandidate( + packageName = packageName, + appLabel = label, + versionName = pkgInfo.versionName, + versionCode = versionCode, + signingFingerprint = fingerprint, + installerKind = installerKind, + manifestHint = manifestHint, + firstSeenAt = firstSeenAt, + ) + } + + private fun longVersionCode(pkgInfo: PackageInfo): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkgInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + pkgInfo.versionCode.toLong() + } + + private fun nowMillis(): Long = System.currentTimeMillis() + + companion object { + private const val GRANT_THRESHOLD = 30 + private const val INVISIBLE_GUESS = 100 + } +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt new file mode 100644 index 000000000..9a3d3456e --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt @@ -0,0 +1,109 @@ +package zed.rainxch.core.data.services.external + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Build +import zed.rainxch.core.domain.system.InstallerKind + +class InstallerSourceClassifier( + private val packageManager: PackageManager, + private val selfPackageName: String, +) { + fun classify( + packageName: String, + applicationInfo: ApplicationInfo?, + ): InstallerKind { + if (packageName == selfPackageName) return InstallerKind.GITHUB_STORE_SELF + + val flags = applicationInfo?.flags ?: 0 + val isSystem = flags and ApplicationInfo.FLAG_SYSTEM != 0 + val isUpdatedSystem = flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + + val installer = installerPackageNameFor(packageName) + + if (installer == null && isSystem && !isUpdatedSystem) { + return InstallerKind.SYSTEM + } + + return mapInstaller(installer, isSystem = isSystem && !isUpdatedSystem) + } + + fun classifyByInstaller(installerPackageName: String?): InstallerKind = mapInstaller(installerPackageName, isSystem = false) + + private fun installerPackageNameFor(packageName: String): String? = + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + }.getOrNull() + + private fun mapInstaller( + installer: String?, + isSystem: Boolean, + ): InstallerKind { + if (installer == null) { + return if (isSystem) InstallerKind.SYSTEM else InstallerKind.SIDELOAD + } + return when (installer) { + in OBTAINIUM_PACKAGES -> InstallerKind.STORE_OBTAINIUM + FDROID -> InstallerKind.STORE_FDROID + PLAY -> InstallerKind.STORE_PLAY + AURORA -> InstallerKind.STORE_AURORA + GALAXY -> InstallerKind.STORE_GALAXY + in OEM_STORES -> InstallerKind.STORE_OEM_OTHER + in BROWSERS -> InstallerKind.BROWSER + in SIDELOAD_PACKAGES -> InstallerKind.SIDELOAD + else -> InstallerKind.UNKNOWN + } + } + + companion object { + private val OBTAINIUM_PACKAGES = + setOf( + "dev.imranr.obtainium", + "dev.imranr.obtainium.app", + ) + private const val FDROID = "org.fdroid.fdroid" + private const val PLAY = "com.android.vending" + private const val AURORA = "com.aurora.store" + private const val GALAXY = "com.sec.android.app.samsungapps" + + private val OEM_STORES = + setOf( + "com.huawei.appmarket", + "com.xiaomi.market", + "com.heytap.market", + "com.oppo.market", + "com.vivo.appstore", + "com.miui.packageinstaller", + ) + + private val BROWSERS = + setOf( + "com.android.chrome", + "com.chrome.beta", + "com.chrome.dev", + "com.chrome.canary", + "com.brave.browser", + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.fenix", + "com.microsoft.emmx", + "com.vivaldi.browser", + "com.sec.android.app.sbrowser", + "com.duckduckgo.mobile.android", + "com.opera.browser", + "com.opera.mini.native", + ) + + private val SIDELOAD_PACKAGES = + setOf( + "com.android.shell", + "com.android.packageinstaller", + "com.google.android.packageinstaller", + ) + } +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt new file mode 100644 index 000000000..eae19479f --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt @@ -0,0 +1,92 @@ +package zed.rainxch.core.data.services.external + +import android.os.Bundle +import zed.rainxch.core.domain.system.ManifestHint +import zed.rainxch.core.domain.system.ManifestHintSource + +class ManifestHintExtractor { + fun extract(metaData: Bundle?): ManifestHint? { + if (metaData == null) return null + + metaData.getString(KEY_GITHUB_REPO)?.trim()?.takeIf { it.isNotEmpty() }?.let { raw -> + parseOwnerRepoLiteral(raw)?.let { (owner, repo) -> + return ManifestHint( + owner = owner, + repo = repo, + source = ManifestHintSource.META_GITHUB_REPO, + confidence = 0.95, + ) + } + } + + for (entry in URL_KEYS) { + val raw = metaData.getString(entry.name)?.trim() ?: continue + val parsed = parseGithubUrl(raw) ?: continue + return ManifestHint( + owner = parsed.first, + repo = parsed.second, + source = entry.source, + confidence = entry.confidence, + ) + } + return null + } + + fun parseOwnerRepoLiteral(raw: String): Pair? { + if (!OWNER_REPO_REGEX.matches(raw)) return null + val parts = raw.split('/') + if (parts.size != 2) return null + return parts[0] to stripRepoSuffix(parts[1]) + } + + fun parseGithubUrl(raw: String): Pair? { + val cleaned = raw.trim().removeSurrounding("\"") + val match = URL_REGEX.find(cleaned) ?: return null + val owner = match.groupValues[1] + val repoSegment = match.groupValues[2] + if (owner.isEmpty() || repoSegment.isEmpty()) return null + if (!OWNER_REGEX.matches(owner)) return null + val repo = stripRepoSuffix(repoSegment) + if (!REPO_NAME_REGEX.matches(repo)) return null + return owner to repo + } + + private fun stripRepoSuffix(repo: String): String { + var trimmed = repo.trimEnd('/') + if (trimmed.endsWith(".git", ignoreCase = true)) { + trimmed = trimmed.removeSuffix(".git").removeSuffix(".GIT") + } + return trimmed + } + + companion object { + const val KEY_GITHUB_REPO = "github_repo" + const val KEY_FDROID_SOURCE_CODE = "org.fdroid.fdroid.SourceCode" + const val KEY_UPSTREAM_URL = "upstream_url" + const val KEY_APP_REPO_URL = "app_repo_url" + + private data class UrlKey( + val name: String, + val source: ManifestHintSource, + val confidence: Double, + ) + + // declaration order = priority order + private val URL_KEYS = + listOf( + UrlKey(KEY_FDROID_SOURCE_CODE, ManifestHintSource.META_FDROID_SOURCE_CODE, 0.85), + UrlKey(KEY_UPSTREAM_URL, ManifestHintSource.META_UPSTREAM_URL, 0.80), + UrlKey(KEY_APP_REPO_URL, ManifestHintSource.META_APP_REPO_URL, 0.75), + ) + + private val OWNER_REPO_REGEX = Regex("^[\\w.-]{1,39}/[\\w.-]{1,100}$") + private val OWNER_REGEX = Regex("^[\\w.-]{1,39}$") + private val REPO_NAME_REGEX = Regex("^[\\w.-]{1,100}$") + + // Matches https?://github.com//(/...)? — first two path segments only + private val URL_REGEX = + Regex( + "(?i)^https?://(?:www\\.)?github\\.com/([\\w.-]+)/([\\w.-]+)(?:[/?#].*)?$", + ) + } +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt new file mode 100644 index 000000000..304890a2b --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt @@ -0,0 +1,65 @@ +package zed.rainxch.core.data.services.external + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES +import android.os.Build +import java.security.MessageDigest + +object SigningFingerprintComputer { + fun compute( + packageManager: PackageManager, + packageName: String, + ): String? = + runCatching { + val info = loadPackageInfo(packageManager, packageName) ?: return null + certBytes(info)?.let(::sha256Hex) + }.getOrNull() + + fun computeFrom(info: PackageInfo): String? = + runCatching { + certBytes(info)?.let(::sha256Hex) + }.getOrNull() + + private fun loadPackageInfo( + pm: PackageManager, + packageName: String, + ): PackageInfo? { + val flags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + GET_SIGNING_CERTIFICATES.toLong() + } else { + @Suppress("DEPRECATION") + PackageManager.GET_SIGNATURES.toLong() + } + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags)) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, flags.toInt()) + } + }.getOrNull() + } + + private fun certBytes(info: PackageInfo): ByteArray? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val sigInfo = info.signingInfo + val certs = + if (sigInfo?.hasMultipleSigners() == true) { + sigInfo.apkContentsSigners + } else { + sigInfo?.signingCertificateHistory + } + certs?.firstOrNull()?.toByteArray() + } else { + @Suppress("DEPRECATION") + info.signatures?.firstOrNull()?.toByteArray() + } + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString(":") { "%02X".format(it) } +} diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt index 70ccaaa11..a9d408cc9 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt @@ -19,7 +19,9 @@ import zed.rainxch.core.data.services.DesktopPackageMonitor import zed.rainxch.core.data.services.DesktopPendingInstallNotifier import zed.rainxch.core.data.services.DesktopUpdateScheduleManager import zed.rainxch.core.data.services.FileLocationsProvider +import zed.rainxch.core.data.services.external.DesktopExternalAppScanner import zed.rainxch.core.domain.system.DownloadProgressNotifier +import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.PendingInstallNotifier @@ -59,6 +61,10 @@ actual val corePlatformModule = module { DesktopPackageMonitor() } + single { + DesktopExternalAppScanner() + } + single { DesktopLocalizationManager() } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/external/DesktopExternalAppScanner.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/external/DesktopExternalAppScanner.kt new file mode 100644 index 000000000..d9f820230 --- /dev/null +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/external/DesktopExternalAppScanner.kt @@ -0,0 +1,20 @@ +package zed.rainxch.core.data.services.external + +import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.ExternalAppScanner +import zed.rainxch.core.domain.system.VisiblePackageEstimate + +class DesktopExternalAppScanner : ExternalAppScanner { + override suspend fun isPermissionGranted(): Boolean = true + + override suspend fun visiblePackageCountEstimate(): VisiblePackageEstimate = + VisiblePackageEstimate( + visibleCount = 0, + invisibleEstimate = 0, + permissionGranted = true, + ) + + override suspend fun snapshot(): List = emptyList() + + override suspend fun snapshotSingle(packageName: String): ExternalAppCandidate? = null +} From 13c019d2191dcc829b0aee7b4ca6d2691b37d153 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 20:57:19 +0500 Subject: [PATCH 05/46] E1: external-import repository skeleton with initial-scan persistence --- .../zed/rainxch/core/data/di/SharedModule.kt | 21 ++ .../ExternalImportRepositoryImpl.kt | 244 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 80b6d8414..2b35f2927 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -16,10 +16,12 @@ import zed.rainxch.core.data.services.DefaultDownloadOrchestrator import zed.rainxch.core.data.data_source.impl.DefaultTokenStore import zed.rainxch.core.data.local.db.AppDatabase import zed.rainxch.core.data.local.db.dao.CacheDao +import zed.rainxch.core.data.local.db.dao.ExternalLinkDao import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.SearchHistoryDao import zed.rainxch.core.data.local.db.dao.SeenRepoDao +import zed.rainxch.core.data.local.db.dao.SigningFingerprintDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.logging.KermitLogger @@ -30,6 +32,7 @@ import zed.rainxch.core.data.network.ProxyManagerSeeding import zed.rainxch.core.data.network.ProxyTesterImpl import zed.rainxch.core.data.network.TranslationClientProvider import zed.rainxch.core.data.repository.AuthenticationStateImpl +import zed.rainxch.core.data.repository.ExternalImportRepositoryImpl import zed.rainxch.core.data.repository.FavouritesRepositoryImpl import zed.rainxch.core.data.repository.InstalledAppsRepositoryImpl import zed.rainxch.core.data.repository.ProxyRepositoryImpl @@ -47,8 +50,10 @@ import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.network.ProxyTester import zed.rainxch.core.domain.system.DownloadOrchestrator +import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.DeviceIdentityRepository +import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.ProxyRepository @@ -186,6 +191,14 @@ val coreModule = ) } + single { + ExternalImportRepositoryImpl( + scanner = get(), + externalLinkDao = get(), + preferences = get(), + ) + } + // Application-scoped download / install orchestrator. Lives // for the process lifetime so downloads survive screen // navigation. ViewModels are observers, never owners. @@ -292,4 +305,12 @@ val databaseModule = single { get().searchHistoryDao } + + single { + get().externalLinkDao + } + + single { + get().signingFingerprintDao + } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt new file mode 100644 index 000000000..acfa0588b --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -0,0 +1,244 @@ +package zed.rainxch.core.data.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlin.time.Clock +import zed.rainxch.core.data.local.db.dao.ExternalLinkDao +import zed.rainxch.core.data.local.db.entities.ExternalLinkEntity +import zed.rainxch.core.domain.repository.ExternalImportRepository +import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.ExternalAppScanner +import zed.rainxch.core.domain.system.ExternalLinkState +import zed.rainxch.core.domain.system.ImportSummary +import zed.rainxch.core.domain.system.RepoMatchResult +import zed.rainxch.core.domain.system.RepoMatchSource +import zed.rainxch.core.domain.system.ScanResult + +class ExternalImportRepositoryImpl( + private val scanner: ExternalAppScanner, + private val externalLinkDao: ExternalLinkDao, + private val preferences: DataStore, +) : ExternalImportRepository { + // Snapshot cache survives only for the lifetime of the process. Decisions + // (linked / skipped / never-ask) are persisted in `external_links`; the + // raw candidate metadata (label, fingerprint, hint) is regenerated on the + // next scan rather than persisted to keep the schema small. + private val candidateSnapshot = MutableStateFlow>(emptyMap()) + + override fun pendingCandidatesFlow(): Flow> = + combine( + candidateSnapshot, + externalLinkDao.observePendingReview(), + ) { snapshot, pendingRows -> + val pendingPackages = pendingRows.map { it.packageName }.toSet() + pendingPackages.mapNotNull { snapshot[it] } + } + + override fun pendingCandidateCountFlow(): Flow = externalLinkDao.observePendingReviewCount() + + override suspend fun scheduleInitialScanIfNeeded() { + val alreadyScanned = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] != null + if (alreadyScanned) return + runCatching { runFullScan() } + .onSuccess { markInitialScanComplete() } + .onFailure { Logger.w(it) { "Initial external scan failed; will retry on next launch." } } + } + + override suspend fun runFullScan(): ScanResult { + val started = nowMillis() + val granted = scanner.isPermissionGranted() + val candidates = scanner.snapshot() + candidateSnapshot.value = candidates.associateBy { it.packageName } + + val now = nowMillis() + var newCandidates = 0 + var pendingReview = 0 + + candidates.forEach { candidate -> + val existing = externalLinkDao.get(candidate.packageName) + val updated = mergeCandidate(existing, candidate, now) + if (existing == null) newCandidates++ + if (updated.state == ExternalLinkState.PENDING_REVIEW.name) pendingReview++ + externalLinkDao.upsert(updated) + } + + return ScanResult( + totalCandidates = candidates.size, + newCandidates = newCandidates, + autoLinked = 0, // wired with backend match resolver in Week 2 + pendingReview = pendingReview, + durationMillis = nowMillis() - started, + permissionGranted = granted, + ) + } + + override suspend fun runDeltaScan(changedPackageNames: Set): ScanResult { + val started = nowMillis() + val granted = scanner.isPermissionGranted() + val now = nowMillis() + var newCandidates = 0 + var pendingReview = 0 + val deltaCandidates = mutableListOf() + + changedPackageNames.forEach { pkg -> + val candidate = scanner.snapshotSingle(pkg) + if (candidate == null) { + externalLinkDao.deleteByPackageName(pkg) + return@forEach + } + deltaCandidates += candidate + val existing = externalLinkDao.get(pkg) + val updated = mergeCandidate(existing, candidate, now) + if (existing == null) newCandidates++ + if (updated.state == ExternalLinkState.PENDING_REVIEW.name) pendingReview++ + externalLinkDao.upsert(updated) + } + + if (deltaCandidates.isNotEmpty()) { + candidateSnapshot.update { current -> + current.toMutableMap().apply { + deltaCandidates.forEach { put(it.packageName, it) } + } + } + } + + return ScanResult( + totalCandidates = deltaCandidates.size, + newCandidates = newCandidates, + autoLinked = 0, + pendingReview = pendingReview, + durationMillis = nowMillis() - started, + permissionGranted = granted, + ) + } + + override suspend fun resolveMatches(candidates: List): List = + // Backend strategy ships in Week 2; manifest-derived matches are already + // persisted by `runFullScan` directly onto the external_links row, so + // returning empty here is correct for the manifest-only path. + emptyList() + + override suspend fun importAutoMatched(matches: List): ImportSummary { + notImplemented("importAutoMatched") + } + + override suspend fun linkManually( + packageName: String, + owner: String, + repo: String, + source: String, + ): Result { + notImplemented("linkManually") + } + + override suspend fun skipPackage( + packageName: String, + neverAsk: Boolean, + ) { + val existing = externalLinkDao.get(packageName) + val state = if (neverAsk) ExternalLinkState.NEVER_ASK else ExternalLinkState.SKIPPED + val now = nowMillis() + val skipExpiresAt = if (neverAsk) null else now + SKIP_TTL_MILLIS + val row = + existing?.copy( + state = state.name, + lastReviewedAt = now, + skipExpiresAt = skipExpiresAt, + ) ?: ExternalLinkEntity( + packageName = packageName, + state = state.name, + repoOwner = null, + repoName = null, + matchSource = null, + matchConfidence = null, + signingFingerprint = null, + installerKind = null, + firstSeenAt = now, + lastReviewedAt = now, + skipExpiresAt = skipExpiresAt, + ) + externalLinkDao.upsert(row) + } + + override suspend fun unlink(packageName: String) { + externalLinkDao.deleteByPackageName(packageName) + candidateSnapshot.update { it - packageName } + } + + override suspend fun rescanSinglePackage(packageName: String): RepoMatchResult? { + notImplemented("rescanSinglePackage") + } + + override suspend fun syncSigningFingerprintSeed() { + notImplemented("syncSigningFingerprintSeed") + } + + override suspend fun pruneExpiredSkips() { + externalLinkDao.pruneExpiredSkips(nowMillis()) + } + + override suspend fun isPermissionGranted(): Boolean = scanner.isPermissionGranted() + + private suspend fun markInitialScanComplete() { + preferences.edit { prefs -> + prefs[INITIAL_SCAN_COMPLETED_AT_KEY] = nowMillis() + } + } + + private fun mergeCandidate( + existing: ExternalLinkEntity?, + candidate: ExternalAppCandidate, + now: Long, + ): ExternalLinkEntity { + if (existing != null && shouldPreserveDecision(existing, now)) { + return existing.copy( + signingFingerprint = candidate.signingFingerprint ?: existing.signingFingerprint, + installerKind = candidate.installerKind.name, + ) + } + + val hint = candidate.manifestHint + return ExternalLinkEntity( + packageName = candidate.packageName, + state = ExternalLinkState.PENDING_REVIEW.name, + repoOwner = hint?.owner ?: existing?.repoOwner, + repoName = hint?.repo ?: existing?.repoName, + matchSource = if (hint != null) RepoMatchSource.MANIFEST.name else existing?.matchSource, + matchConfidence = hint?.confidence ?: existing?.matchConfidence, + signingFingerprint = candidate.signingFingerprint, + installerKind = candidate.installerKind.name, + firstSeenAt = existing?.firstSeenAt ?: now, + lastReviewedAt = now, + skipExpiresAt = null, + ) + } + + private fun shouldPreserveDecision( + existing: ExternalLinkEntity, + now: Long, + ): Boolean = + when (existing.state) { + ExternalLinkState.MATCHED.name -> true + ExternalLinkState.NEVER_ASK.name -> true + ExternalLinkState.SKIPPED.name -> (existing.skipExpiresAt ?: 0) > now + else -> false + } + + private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() + + private fun notImplemented(name: String): Nothing = + error("ExternalImportRepository.$name is not implemented yet (Week 2/3 of E1).") + + companion object { + private val INITIAL_SCAN_COMPLETED_AT_KEY = longPreferencesKey("external_import_initial_scan_at") + private const val SKIP_TTL_MILLIS: Long = 7L * 24 * 60 * 60 * 1000 + } +} From 3d30236e2ca39d1c818d9528d2903e7b334d7539 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 21:25:31 +0500 Subject: [PATCH 06/46] E1: external-import contract types, banner state, route, tweaks flags --- .../src/androidMain/AndroidManifest.xml | 14 ++++++++ .../app/navigation/AppNavigation.kt | 3 ++ .../app/navigation/GithubStoreGraph.kt | 3 ++ .../data/repository/TweaksRepositoryImpl.kt | 24 +++++++++++++ .../domain/repository/TweaksRepository.kt | 8 +++++ .../rainxch/apps/presentation/AppsAction.kt | 5 +++ .../rainxch/apps/presentation/AppsEvent.kt | 2 ++ .../zed/rainxch/apps/presentation/AppsRoot.kt | 5 +++ .../rainxch/apps/presentation/AppsState.kt | 4 +++ .../apps/presentation/AppsViewModel.kt | 11 ++++++ .../import/ExternalImportAction.kt | 33 +++++++++++++++++ .../import/ExternalImportEvent.kt | 11 ++++++ .../import/ExternalImportState.kt | 36 +++++++++++++++++++ .../presentation/import/model/CandidateUi.kt | 13 +++++++ .../presentation/import/model/ImportPhase.kt | 10 ++++++ .../import/model/RepoSuggestionUi.kt | 18 ++++++++++ 16 files changed, 200 insertions(+) create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/CandidateUi.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 75735631a..383f5b145 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -18,6 +18,20 @@ + + + + + + + + + = + preferences.data.map { prefs -> + prefs[EXTERNAL_IMPORT_ENABLED_KEY] ?: true + } + + override suspend fun setExternalImportEnabled(enabled: Boolean) { + preferences.edit { prefs -> + prefs[EXTERNAL_IMPORT_ENABLED_KEY] = enabled + } + } + + override fun getExternalMatchSearchEnabled(): Flow = + preferences.data.map { prefs -> + prefs[EXTERNAL_MATCH_SEARCH_ENABLED_KEY] ?: false + } + + override suspend fun setExternalMatchSearchEnabled(enabled: Boolean) { + preferences.edit { prefs -> + prefs[EXTERNAL_MATCH_SEARCH_ENABLED_KEY] = enabled + } + } + companion object { private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L @@ -251,5 +273,7 @@ class TweaksRepositoryImpl( private val YOUDAO_APP_KEY = stringPreferencesKey("youdao_app_key") private val YOUDAO_APP_SECRET = stringPreferencesKey("youdao_app_secret") private val APP_LANGUAGE_KEY = stringPreferencesKey("app_language") + private val EXTERNAL_IMPORT_ENABLED_KEY = booleanPreferencesKey("external_import_enabled") + private val EXTERNAL_MATCH_SEARCH_ENABLED_KEY = booleanPreferencesKey("external_match_search_enabled") } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index abd7ea7a5..7e3df7b22 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -86,4 +86,12 @@ interface TweaksRepository { fun getAppLanguage(): Flow suspend fun setAppLanguage(tag: String?) + + fun getExternalImportEnabled(): Flow + + suspend fun setExternalImportEnabled(enabled: Boolean) + + fun getExternalMatchSearchEnabled(): Flow + + suspend fun setExternalMatchSearchEnabled(enabled: Boolean) } 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 1cc9645d7..607de0428 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 @@ -100,4 +100,9 @@ sealed interface AppsAction { data class OnInstallPendingApp( val app: InstalledAppUi, ) : AppsAction + + // External import banner (E1) + data object OnImportProposalReview : AppsAction + + data object OnImportProposalDismiss : AppsAction } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt index 2690baee7..aa1c6ed0d 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt @@ -22,4 +22,6 @@ sealed interface AppsEvent { data class ImportComplete( val result: ImportResult, ) : AppsEvent + + data object NavigateToExternalImport : AppsEvent } 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 e7ca8b236..8e93d6381 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 @@ -147,6 +147,7 @@ import zed.rainxch.githubstore.core.presentation.res.updating_x_of_y fun AppsRoot( onNavigateBack: () -> Unit, onNavigateToRepo: (repoId: Long) -> Unit, + onNavigateToExternalImport: () -> Unit, viewModel: AppsViewModel = koinViewModel(), state: AppsState, ) { @@ -176,6 +177,10 @@ fun AppsRoot( is AppsEvent.ImportComplete -> { // handled by ShowSuccess } + + AppsEvent.NavigateToExternalImport -> { + onNavigateToExternalImport() + } } } 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 91705521e..4d633e10f 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 @@ -76,6 +76,10 @@ data class AppsState( val isImporting: Boolean = false, // Uninstall confirmation val appPendingUninstall: InstalledAppUi? = null, + // External import banner (E1) + val pendingExternalImportCount: Int = 0, + val showImportProposalBanner: Boolean = false, + val isExternalImportInFlight: Boolean = false, ) { val filteredDeviceApps: ImmutableList get() = 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 1b13d5718..6117fbbd4 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 @@ -441,6 +441,17 @@ class AppsViewModel( AppsAction.OnDismissUninstallDialog -> { _state.update { it.copy(appPendingUninstall = null) } } + + AppsAction.OnImportProposalReview -> { + _state.update { it.copy(showImportProposalBanner = false) } + viewModelScope.launch { + _events.send(AppsEvent.NavigateToExternalImport) + } + } + + AppsAction.OnImportProposalDismiss -> { + _state.update { it.copy(showImportProposalBanner = false) } + } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt new file mode 100644 index 000000000..c60e4b899 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt @@ -0,0 +1,33 @@ +package zed.rainxch.apps.presentation.import + +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi + +sealed interface ExternalImportAction { + data object OnStart : ExternalImportAction + + data object OnRequestPermission : ExternalImportAction + + data object OnPermissionGranted : ExternalImportAction + + data object OnPermissionDenied : ExternalImportAction + + data object OnSkipCurrentCard : ExternalImportAction + + data object OnSkipForever : ExternalImportAction + + data class OnPickSuggestion(val suggestion: RepoSuggestionUi) : ExternalImportAction + + data object OnExpandCurrentCard : ExternalImportAction + + data object OnCollapseCurrentCard : ExternalImportAction + + data class OnSearchOverrideChanged(val query: String) : ExternalImportAction + + data object OnSearchOverrideSubmit : ExternalImportAction + + data object OnUndoLast : ExternalImportAction + + data object OnExit : ExternalImportAction + + data object OnDismissCompletionToast : ExternalImportAction +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt new file mode 100644 index 000000000..10e9dc373 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt @@ -0,0 +1,11 @@ +package zed.rainxch.apps.presentation.import + +sealed interface ExternalImportEvent { + data class ShowError(val message: String) : ExternalImportEvent + + data class NavigateToDetails(val repoId: Long) : ExternalImportEvent + + data object NavigateBack : ExternalImportEvent + + data object PlayConfetti : ExternalImportEvent +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt new file mode 100644 index 000000000..80d0308b8 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt @@ -0,0 +1,36 @@ +package zed.rainxch.apps.presentation.import + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import zed.rainxch.apps.presentation.import.model.CandidateUi +import zed.rainxch.apps.presentation.import.model.ImportPhase +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi + +data class ExternalImportState( + val phase: ImportPhase = ImportPhase.Idle, + val totalCandidates: Int = 0, + val autoImported: Int = 0, + val skipped: Int = 0, + val manuallyLinked: Int = 0, + val cards: ImmutableList = persistentListOf(), + val currentCardIndex: Int = 0, + val currentExpanded: Boolean = false, + val searchOverrideQuery: String = "", + val searchOverrideResults: ImmutableList = persistentListOf(), + val isSearching: Boolean = false, + val searchError: String? = null, + val isPermissionDenied: Boolean = false, + val visiblePackageCount: Int = 0, + val invisiblePackageCountEstimate: Int = 0, + val showCompletionToast: Boolean = false, + val errorMessage: String? = null, +) { + val currentCard: CandidateUi? + get() = cards.getOrNull(currentCardIndex) + + val cardsRemaining: Int + get() = (cards.size - currentCardIndex).coerceAtLeast(0) + + val isWizardComplete: Boolean + get() = phase == ImportPhase.Done || (cards.isNotEmpty() && currentCardIndex >= cards.size) +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/CandidateUi.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/CandidateUi.kt new file mode 100644 index 000000000..97e90820f --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/CandidateUi.kt @@ -0,0 +1,13 @@ +package zed.rainxch.apps.presentation.import.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class CandidateUi( + val packageName: String, + val appLabel: String, + val versionName: String?, + val installerLabel: String, + val suggestions: ImmutableList = persistentListOf(), + val preselectedSuggestion: RepoSuggestionUi? = null, +) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt new file mode 100644 index 000000000..cc2ef5b2c --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt @@ -0,0 +1,10 @@ +package zed.rainxch.apps.presentation.import.model + +enum class ImportPhase { + Idle, + RequestingPermission, + Scanning, + AutoImporting, + AwaitingReview, + Done, +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt new file mode 100644 index 000000000..1fde5ebba --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt @@ -0,0 +1,18 @@ +package zed.rainxch.apps.presentation.import.model + +data class RepoSuggestionUi( + val owner: String, + val repo: String, + val confidence: Double, + val source: SuggestionSource, + val stars: Int? = null, + val description: String? = null, +) { + val ownerSlashRepo: String get() = "$owner/$repo" +} + +enum class SuggestionSource { + MANIFEST, + SEARCH, + FINGERPRINT, +} From 6c7cf8d8bd86d44280c8d0cea4f27d08d21fb0b4 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 21:43:05 +0500 Subject: [PATCH 07/46] E1: backend match api, mock, selector, and repository fill --- .../zed/rainxch/core/data/di/SharedModule.kt | 17 +++ .../core/data/dto/ExternalMatchRequest.kt | 24 ++++ .../core/data/dto/ExternalMatchResponse.kt | 24 ++++ .../dto/SigningFingerprintSeedResponse.kt | 17 +++ .../core/data/mappers/ExternalMatchMappers.kt | 60 +++++++++ .../core/data/network/BackendApiClient.kt | 18 +++ .../core/data/network/ExternalMatchApi.kt | 59 +++++++++ .../ExternalImportRepositoryImpl.kt | 120 ++++++++++++++++-- 8 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchRequest.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchResponse.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ExternalMatchMappers.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 2b35f2927..844ef9f2f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -26,7 +26,11 @@ import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.logging.KermitLogger import zed.rainxch.core.data.network.BackendApiClient +import zed.rainxch.core.data.network.BackendExternalMatchApi +import zed.rainxch.core.data.network.ExternalMatchApi +import zed.rainxch.core.data.network.ExternalMatchApiSelector import zed.rainxch.core.data.network.GitHubClientProvider +import zed.rainxch.core.data.network.MockExternalMatchApi import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.data.network.ProxyManagerSeeding import zed.rainxch.core.data.network.ProxyTesterImpl @@ -191,11 +195,24 @@ val coreModule = ) } + single { BackendExternalMatchApi(get()) } + + single { MockExternalMatchApi() } + + single { + ExternalMatchApiSelector( + real = get(), + mock = get(), + tweaks = get(), + ) + } + single { ExternalImportRepositoryImpl( scanner = get(), externalLinkDao = get(), preferences = get(), + externalMatchApi = get(), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchRequest.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchRequest.kt new file mode 100644 index 000000000..f712c8bdb --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchRequest.kt @@ -0,0 +1,24 @@ +package zed.rainxch.core.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ExternalMatchRequest( + val platform: String, + val candidates: List, +) { + @Serializable + data class RequestItem( + val packageName: String, + val appLabel: String, + val signingFingerprint: String? = null, + val installerKind: String? = null, + val manifestHint: ManifestHintDto? = null, + ) + + @Serializable + data class ManifestHintDto( + val owner: String, + val repo: String, + ) +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchResponse.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchResponse.kt new file mode 100644 index 000000000..4cbd52068 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchResponse.kt @@ -0,0 +1,24 @@ +package zed.rainxch.core.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ExternalMatchResponse( + val matches: List, +) { + @Serializable + data class MatchEntry( + val packageName: String, + val candidates: List, + ) + + @Serializable + data class MatchCandidate( + val owner: String, + val repo: String, + val confidence: Double, + val source: String, + val stars: Int? = null, + val description: String? = null, + ) +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt new file mode 100644 index 000000000..83b8114ec --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt @@ -0,0 +1,17 @@ +package zed.rainxch.core.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SigningFingerprintSeedResponse( + val rows: List, + val nextCursor: String? = null, +) { + @Serializable + data class Row( + val fingerprint: String, + val owner: String, + val repo: String, + val observedAt: Long, + ) +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ExternalMatchMappers.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ExternalMatchMappers.kt new file mode 100644 index 000000000..3dd149f89 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ExternalMatchMappers.kt @@ -0,0 +1,60 @@ +package zed.rainxch.core.data.mappers + +import zed.rainxch.core.data.dto.ExternalMatchRequest +import zed.rainxch.core.data.dto.ExternalMatchResponse +import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.InstallerKind +import zed.rainxch.core.domain.system.RepoMatchResult +import zed.rainxch.core.domain.system.RepoMatchSource +import zed.rainxch.core.domain.system.RepoMatchSuggestion + +fun ExternalAppCandidate.toRequestItem(): ExternalMatchRequest.RequestItem = + ExternalMatchRequest.RequestItem( + packageName = packageName, + appLabel = appLabel, + signingFingerprint = signingFingerprint, + installerKind = installerKind.toWireString(), + manifestHint = manifestHint?.let { + ExternalMatchRequest.ManifestHintDto(owner = it.owner, repo = it.repo) + }, + ) + +fun ExternalMatchResponse.toRepoMatchResults(): List = + matches.map { entry -> + RepoMatchResult( + packageName = entry.packageName, + suggestions = entry.candidates.map { c -> + RepoMatchSuggestion( + owner = c.owner, + repo = c.repo, + confidence = c.confidence, + source = c.source.toRepoMatchSource(), + stars = c.stars, + description = c.description, + ) + }, + ) + } + +private fun InstallerKind.toWireString(): String = + when (this) { + InstallerKind.STORE_OBTAINIUM -> "obtainium" + InstallerKind.STORE_FDROID -> "fdroid" + InstallerKind.STORE_PLAY -> "play" + InstallerKind.STORE_AURORA -> "aurora" + InstallerKind.STORE_GALAXY -> "galaxy" + InstallerKind.STORE_OEM_OTHER -> "oem_other" + InstallerKind.BROWSER -> "browser" + InstallerKind.SIDELOAD -> "sideload" + InstallerKind.SYSTEM -> "system" + InstallerKind.GITHUB_STORE_SELF -> "github_store_self" + InstallerKind.UNKNOWN -> "unknown" + } + +private fun String.toRepoMatchSource(): RepoMatchSource = + when (this) { + "manifest" -> RepoMatchSource.MANIFEST + "search" -> RepoMatchSource.SEARCH + "fingerprint" -> RepoMatchSource.FINGERPRINT + else -> RepoMatchSource.SEARCH + } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index da3c59387..4c9fc2f92 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -33,6 +33,8 @@ import zed.rainxch.core.data.dto.BackendExploreResponse import zed.rainxch.core.data.dto.BackendRepoResponse import zed.rainxch.core.data.dto.BackendSearchResponse import zed.rainxch.core.data.dto.EventRequest +import zed.rainxch.core.data.dto.ExternalMatchRequest +import zed.rainxch.core.data.dto.ExternalMatchResponse import zed.rainxch.core.data.dto.GithubReadmeResponseDto import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.dto.UserProfileNetwork @@ -241,6 +243,22 @@ class BackendApiClient( } } + suspend fun postExternalMatch(body: ExternalMatchRequest): Result = + safeCall { + val response = httpClient.post("external-match") { + contentType(ContentType.Application.Json) + setBody(body) + } + when { + response.status.isSuccess() -> + Result.success(response.body()) + response.status == HttpStatusCode.TooManyRequests -> + Result.failure(RateLimitedException()) + else -> + Result.failure(BackendException(response.status.value)) + } + } + suspend fun postEvents(events: List): Result = safeCall { val response = httpClient.post("events") { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt new file mode 100644 index 000000000..e23e136b9 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt @@ -0,0 +1,59 @@ +package zed.rainxch.core.data.network + +import kotlinx.coroutines.flow.first +import zed.rainxch.core.data.dto.ExternalMatchRequest +import zed.rainxch.core.data.dto.ExternalMatchResponse +import zed.rainxch.core.domain.repository.TweaksRepository + +interface ExternalMatchApi { + suspend fun match(request: ExternalMatchRequest): Result +} + +class BackendExternalMatchApi( + private val backendClient: BackendApiClient, +) : ExternalMatchApi { + override suspend fun match(request: ExternalMatchRequest): Result { + if (request.candidates.size <= MAX_BATCH_SIZE) { + return backendClient.postExternalMatch(request) + } + val merged = mutableListOf() + for (batch in request.candidates.chunked(MAX_BATCH_SIZE)) { + val sub = ExternalMatchRequest(platform = request.platform, candidates = batch) + val result = backendClient.postExternalMatch(sub) + result.onFailure { return Result.failure(it) } + result.onSuccess { merged += it.matches } + } + return Result.success(ExternalMatchResponse(matches = merged)) + } + + companion object { + private const val MAX_BATCH_SIZE = 25 + } +} + +class MockExternalMatchApi : ExternalMatchApi { + override suspend fun match(request: ExternalMatchRequest): Result = + Result.success( + ExternalMatchResponse( + matches = request.candidates.map { + ExternalMatchResponse.MatchEntry( + packageName = it.packageName, + candidates = emptyList(), + ) + }, + ), + ) +} + +class ExternalMatchApiSelector( + private val real: BackendExternalMatchApi, + private val mock: MockExternalMatchApi, + private val tweaks: TweaksRepository, +) : ExternalMatchApi { + override suspend fun match(request: ExternalMatchRequest): Result = + if (tweaks.getExternalMatchSearchEnabled().first()) { + real.match(request) + } else { + mock.match(request) + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index acfa0588b..c04209be4 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -11,8 +11,12 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlin.time.Clock +import zed.rainxch.core.data.dto.ExternalMatchRequest import zed.rainxch.core.data.local.db.dao.ExternalLinkDao import zed.rainxch.core.data.local.db.entities.ExternalLinkEntity +import zed.rainxch.core.data.mappers.toRepoMatchResults +import zed.rainxch.core.data.mappers.toRequestItem +import zed.rainxch.core.data.network.ExternalMatchApi import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner @@ -20,12 +24,14 @@ import zed.rainxch.core.domain.system.ExternalLinkState import zed.rainxch.core.domain.system.ImportSummary import zed.rainxch.core.domain.system.RepoMatchResult import zed.rainxch.core.domain.system.RepoMatchSource +import zed.rainxch.core.domain.system.RepoMatchSuggestion import zed.rainxch.core.domain.system.ScanResult class ExternalImportRepositoryImpl( private val scanner: ExternalAppScanner, private val externalLinkDao: ExternalLinkDao, private val preferences: DataStore, + private val externalMatchApi: ExternalMatchApi, ) : ExternalImportRepository { // Snapshot cache survives only for the lifetime of the process. Decisions // (linked / skipped / never-ask) are persisted in `external_links`; the @@ -120,14 +126,82 @@ class ExternalImportRepositoryImpl( ) } - override suspend fun resolveMatches(candidates: List): List = - // Backend strategy ships in Week 2; manifest-derived matches are already - // persisted by `runFullScan` directly onto the external_links row, so - // returning empty here is correct for the manifest-only path. - emptyList() + override suspend fun resolveMatches(candidates: List): List { + if (candidates.isEmpty()) return emptyList() + + val backendResults = mutableMapOf>() + for (batch in candidates.chunked(MATCH_BATCH_SIZE)) { + val request = + ExternalMatchRequest( + platform = "android", + candidates = batch.map { it.toRequestItem() }, + ) + externalMatchApi + .match(request) + .onSuccess { response -> + response.toRepoMatchResults().forEach { result -> + backendResults + .getOrPut(result.packageName) { mutableListOf() } + .addAll(result.suggestions) + } + }.onFailure { Logger.w(it) { "external-match batch failed; continuing" } } + } + + return candidates.map { candidate -> + val suggestions = mutableListOf() + candidate.manifestHint?.let { hint -> + suggestions += RepoMatchSuggestion( + owner = hint.owner, + repo = hint.repo, + confidence = hint.confidence, + source = RepoMatchSource.MANIFEST, + ) + } + backendResults[candidate.packageName]?.let { suggestions += it } + RepoMatchResult( + packageName = candidate.packageName, + suggestions = suggestions + .distinctBy { "${it.owner}/${it.repo}" } + .sortedByDescending { it.confidence }, + ) + } + } override suspend fun importAutoMatched(matches: List): ImportSummary { - notImplemented("importAutoMatched") + var linked = 0 + val now = nowMillis() + matches.forEach { result -> + val top = result.topSuggestion + if (top != null && top.confidence >= AUTO_LINK_CONFIDENCE_THRESHOLD) { + val existing = externalLinkDao.get(result.packageName) + val base = existing ?: ExternalLinkEntity( + packageName = result.packageName, + state = ExternalLinkState.MATCHED.name, + repoOwner = top.owner, + repoName = top.repo, + matchSource = top.source.name.lowercase(), + matchConfidence = top.confidence, + signingFingerprint = null, + installerKind = null, + firstSeenAt = now, + lastReviewedAt = now, + skipExpiresAt = null, + ) + externalLinkDao.upsert( + base.copy( + state = ExternalLinkState.MATCHED.name, + repoOwner = top.owner, + repoName = top.repo, + matchSource = top.source.name.lowercase(), + matchConfidence = top.confidence, + lastReviewedAt = now, + ), + ) + linked++ + } + } + // TODO Week 2 day 11: also call AppsRepository.linkAppToRepo to materialize installed_apps rows + return ImportSummary(attempted = matches.size, linked = linked, failed = 0) } override suspend fun linkManually( @@ -136,7 +210,33 @@ class ExternalImportRepositoryImpl( repo: String, source: String, ): Result { - notImplemented("linkManually") + val now = nowMillis() + val existing = externalLinkDao.get(packageName) + val base = existing ?: ExternalLinkEntity( + packageName = packageName, + state = ExternalLinkState.MATCHED.name, + repoOwner = owner, + repoName = repo, + matchSource = source, + matchConfidence = 1.0, + signingFingerprint = null, + installerKind = null, + firstSeenAt = now, + lastReviewedAt = now, + skipExpiresAt = null, + ) + externalLinkDao.upsert( + base.copy( + state = ExternalLinkState.MATCHED.name, + repoOwner = owner, + repoName = repo, + matchSource = source, + matchConfidence = 1.0, + lastReviewedAt = now, + ), + ) + // TODO Week 2 day 11: AppsRepository.linkAppToRepo + return Result.success(Unit) } override suspend fun skipPackage( @@ -174,7 +274,9 @@ class ExternalImportRepositoryImpl( } override suspend fun rescanSinglePackage(packageName: String): RepoMatchResult? { - notImplemented("rescanSinglePackage") + val candidate = scanner.snapshotSingle(packageName) ?: return null + candidateSnapshot.update { it + (packageName to candidate) } + return resolveMatches(listOf(candidate)).firstOrNull() } override suspend fun syncSigningFingerprintSeed() { @@ -240,5 +342,7 @@ class ExternalImportRepositoryImpl( companion object { private val INITIAL_SCAN_COMPLETED_AT_KEY = longPreferencesKey("external_import_initial_scan_at") private const val SKIP_TTL_MILLIS: Long = 7L * 24 * 60 * 60 * 1000 + private const val MATCH_BATCH_SIZE = 25 + private const val AUTO_LINK_CONFIDENCE_THRESHOLD = 0.85 } } From e59353b0f47d0bfebeda4ca2ab1e0e45b85dc8ef Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 21:43:19 +0500 Subject: [PATCH 08/46] E1: external import view model and apps banner observer --- .../githubstore/app/di/ViewModelsModule.kt | 2 + .../apps/presentation/AppsViewModel.kt | 16 + .../import/ExternalImportViewModel.kt | 310 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt index 9ceb12664..55ee6ae76 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt @@ -4,6 +4,7 @@ import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module import zed.rainxch.apps.presentation.AppsViewModel +import zed.rainxch.apps.presentation.import.ExternalImportViewModel import zed.rainxch.auth.presentation.AuthenticationViewModel import zed.rainxch.details.presentation.DetailsViewModel import zed.rainxch.devprofile.presentation.DeveloperProfileViewModel @@ -18,6 +19,7 @@ import zed.rainxch.tweaks.presentation.TweaksViewModel val viewModelsModule = module { viewModelOf(::AppsViewModel) + viewModelOf(::ExternalImportViewModel) viewModelOf(::AuthenticationViewModel) viewModel { params -> // Indexed access because `ownerParam` and `repoParam` are both 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 6117fbbd4..dfed57987 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 @@ -32,6 +32,7 @@ import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.network.Downloader +import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.DownloadOrchestrator @@ -58,6 +59,7 @@ class AppsViewModel( private val shareManager: ShareManager, private val tweaksRepository: TweaksRepository, private val downloadOrchestrator: DownloadOrchestrator, + private val externalImportRepository: ExternalImportRepository, ) : ViewModel() { companion object { private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L @@ -78,6 +80,7 @@ class AppsViewModel( if (!hasLoadedInitialData) { loadApps() observeLiquidGlassEnabled() + observePendingExternalImports() hasLoadedInitialData = true } }.stateIn( @@ -98,6 +101,19 @@ class AppsViewModel( } } + private fun observePendingExternalImports() { + viewModelScope.launch { + externalImportRepository.pendingCandidateCountFlow().collect { count -> + _state.update { + it.copy( + pendingExternalImportCount = count, + showImportProposalBanner = count >= 3 && !it.isExternalImportInFlight, + ) + } + } + } + } + private val _events = Channel() val events = _events.receiveAsFlow() diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt new file mode 100644 index 000000000..d1eada563 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -0,0 +1,310 @@ +package zed.rainxch.apps.presentation.import + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import zed.rainxch.apps.presentation.import.model.CandidateUi +import zed.rainxch.apps.presentation.import.model.ImportPhase +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi +import zed.rainxch.apps.presentation.import.model.SuggestionSource +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.repository.ExternalImportRepository +import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.InstallerKind +import zed.rainxch.core.domain.system.RepoMatchResult +import zed.rainxch.core.domain.system.RepoMatchSource +import zed.rainxch.core.domain.system.RepoMatchSuggestion + +class ExternalImportViewModel( + private val externalImportRepository: ExternalImportRepository, + private val logger: GitHubStoreLogger, +) : ViewModel() { + private var hasStarted = false + + private val _state = MutableStateFlow(ExternalImportState()) + val state = + _state + .onStart { + if (!hasStarted) { + hasStarted = true + startScanIfIdle() + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = ExternalImportState(), + ) + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun onAction(action: ExternalImportAction) { + when (action) { + ExternalImportAction.OnStart -> { + if (_state.value.phase == ImportPhase.Idle) startScanIfIdle() + } + + ExternalImportAction.OnRequestPermission -> { + _state.update { it.copy(phase = ImportPhase.RequestingPermission) } + } + + ExternalImportAction.OnPermissionGranted -> { + _state.update { it.copy(isPermissionDenied = false) } + startScanIfIdle(force = true) + } + + ExternalImportAction.OnPermissionDenied -> { + _state.update { it.copy(isPermissionDenied = true) } + startScanIfIdle(force = true) + } + + ExternalImportAction.OnSkipCurrentCard -> skipCurrent(neverAsk = false) + + ExternalImportAction.OnSkipForever -> skipCurrent(neverAsk = true) + + is ExternalImportAction.OnPickSuggestion -> pickSuggestion(action.suggestion) + + ExternalImportAction.OnExpandCurrentCard -> { + _state.update { it.copy(currentExpanded = true) } + } + + ExternalImportAction.OnCollapseCurrentCard -> { + _state.update { it.copy(currentExpanded = false) } + } + + is ExternalImportAction.OnSearchOverrideChanged -> { + _state.update { it.copy(searchOverrideQuery = action.query) } + } + + ExternalImportAction.OnSearchOverrideSubmit -> { + // TODO Week 3: wire to BackendApiClient.search + _state.update { it.copy(isSearching = true) } + _state.update { + it.copy( + isSearching = false, + searchOverrideResults = persistentListOf(), + ) + } + } + + ExternalImportAction.OnUndoLast -> Unit + + ExternalImportAction.OnExit -> { + viewModelScope.launch { + _events.send(ExternalImportEvent.NavigateBack) + } + } + + ExternalImportAction.OnDismissCompletionToast -> { + _state.update { it.copy(showCompletionToast = false) } + } + } + } + + private fun startScanIfIdle(force: Boolean = false) { + if (!force && _state.value.phase != ImportPhase.Idle) return + viewModelScope.launch { + try { + _state.update { it.copy(phase = ImportPhase.Scanning, errorMessage = null) } + + externalImportRepository.runFullScan() + + val candidates = externalImportRepository.pendingCandidatesFlow().first() + + _state.update { + it.copy( + phase = ImportPhase.AutoImporting, + totalCandidates = candidates.size, + ) + } + + val matches = externalImportRepository.resolveMatches(candidates) + val summary = externalImportRepository.importAutoMatched(matches) + + val autoLinkedPackages = + matches + .filter { it.topConfidence >= AUTO_LINK_THRESHOLD } + .map { it.packageName } + .toSet() + + val reviewCandidates = + candidates.filter { it.packageName !in autoLinkedPackages } + val reviewMatchesByPkg = + matches.associateBy { it.packageName } + + val cards = + reviewCandidates + .mapNotNull { candidate -> + val match = reviewMatchesByPkg[candidate.packageName] + buildCard(candidate, match) + }.toImmutableList() + + if (cards.isEmpty()) { + _state.update { + it.copy( + phase = ImportPhase.Done, + cards = persistentListOf(), + currentCardIndex = 0, + autoImported = summary.linked, + showCompletionToast = true, + ) + } + _events.send(ExternalImportEvent.PlayConfetti) + } else { + _state.update { + it.copy( + phase = ImportPhase.AwaitingReview, + cards = cards, + currentCardIndex = 0, + currentExpanded = false, + autoImported = summary.linked, + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("External import scan failed: ${e.message}") + _state.update { + it.copy( + phase = ImportPhase.Idle, + errorMessage = e.message, + ) + } + _events.send(ExternalImportEvent.ShowError(e.message ?: "Scan failed")) + } + } + } + + private fun buildCard( + candidate: ExternalAppCandidate, + match: RepoMatchResult?, + ): CandidateUi? { + val suggestionsDomain = match?.suggestions.orEmpty() + val top = suggestionsDomain.maxByOrNull { it.confidence } + val preselected = + if (top != null && top.confidence in PRESELECT_MIN..PRESELECT_MAX) top.toUi() else null + + return CandidateUi( + packageName = candidate.packageName, + appLabel = candidate.appLabel, + versionName = candidate.versionName, + installerLabel = candidate.installerKind.toUiLabel(), + suggestions = suggestionsDomain.take(3).map { it.toUi() }.toImmutableList(), + preselectedSuggestion = preselected, + ) + } + + private fun skipCurrent(neverAsk: Boolean) { + val current = _state.value.currentCard ?: return + viewModelScope.launch { + try { + externalImportRepository.skipPackage(current.packageName, neverAsk = neverAsk) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Skip failed for ${current.packageName}: ${e.message}") + } + advanceAfter { it.copy(skipped = it.skipped + 1) } + } + } + + private fun pickSuggestion(suggestion: RepoSuggestionUi) { + val current = _state.value.currentCard ?: return + val preselected = current.preselectedSuggestion + val source = if (suggestion == preselected) "preselected" else "alternative" + + viewModelScope.launch { + val result = + try { + externalImportRepository.linkManually( + packageName = current.packageName, + owner = suggestion.owner, + repo = suggestion.repo, + source = source, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + + if (result.isFailure) { + logger.error( + "Manual link failed for ${current.packageName}: " + + "${result.exceptionOrNull()?.message}", + ) + _events.send( + ExternalImportEvent.ShowError( + result.exceptionOrNull()?.message ?: "Link failed", + ), + ) + return@launch + } + advanceAfter { it.copy(manuallyLinked = it.manuallyLinked + 1) } + } + } + + private suspend fun advanceAfter(transform: (ExternalImportState) -> ExternalImportState) { + val nextIndex = _state.value.currentCardIndex + 1 + val total = _state.value.cards.size + val done = nextIndex >= total + + _state.update { current -> + transform(current).copy( + currentCardIndex = nextIndex, + currentExpanded = false, + phase = if (done) ImportPhase.Done else current.phase, + showCompletionToast = if (done) true else current.showCompletionToast, + ) + } + + if (done) { + _events.send(ExternalImportEvent.PlayConfetti) + } + } + + private fun RepoMatchSuggestion.toUi(): RepoSuggestionUi = + RepoSuggestionUi( + owner = owner, + repo = repo, + confidence = confidence, + source = + when (source) { + RepoMatchSource.MANIFEST -> SuggestionSource.MANIFEST + RepoMatchSource.SEARCH -> SuggestionSource.SEARCH + RepoMatchSource.FINGERPRINT -> SuggestionSource.FINGERPRINT + RepoMatchSource.MANUAL -> SuggestionSource.MANIFEST + }, + stars = stars, + description = description, + ) + + private fun InstallerKind.toUiLabel(): String = + when (this) { + InstallerKind.STORE_OBTAINIUM -> "Obtainium" + InstallerKind.STORE_FDROID -> "F-Droid" + InstallerKind.BROWSER -> "Browser" + InstallerKind.SIDELOAD -> "Sideload" + InstallerKind.GITHUB_STORE_SELF -> "GitHub Store" + InstallerKind.UNKNOWN -> "Unknown source" + else -> "Unknown source" + } + + companion object { + private const val AUTO_LINK_THRESHOLD = 0.85 + private const val PRESELECT_MIN = 0.5 + private const val PRESELECT_MAX = 0.85 + } +} From 9d93d1e3a64530467c305253b5702bf7e4e12cf8 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 21:43:23 +0500 Subject: [PATCH 09/46] E1: external import wizard composables and proposal banner --- .../presentation/import/ExternalImportRoot.kt | 184 +++++++++++++ .../import/components/CandidateCard.kt | 202 ++++++++++++++ .../import/components/CompletionToast.kt | 74 +++++ .../import/components/EmptyStateScreen.kt | 94 +++++++ .../import/components/ImportProgressScreen.kt | 67 +++++ .../import/components/ImportProposalBanner.kt | 90 ++++++ .../components/PermissionRationaleScreen.kt | 81 ++++++ .../import/components/RepoCandidateRow.kt | 110 ++++++++ .../import/components/RepoSearchOverride.kt | 105 +++++++ .../import/components/WizardCardStack.kt | 259 ++++++++++++++++++ 10 files changed, 1266 insertions(+) create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt new file mode 100644 index 000000000..94d0585a5 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -0,0 +1,184 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package zed.rainxch.apps.presentation.import + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.apps.presentation.import.components.CompletionToast +import zed.rainxch.apps.presentation.import.components.EmptyStateScreen +import zed.rainxch.apps.presentation.import.components.ImportProgressScreen +import zed.rainxch.apps.presentation.import.components.PermissionRationaleScreen +import zed.rainxch.apps.presentation.import.components.WizardCardStack +import zed.rainxch.apps.presentation.import.model.ImportPhase +import zed.rainxch.core.presentation.utils.ObserveAsEvents + +@Composable +fun ExternalImportRoot( + onNavigateBack: () -> Unit, + onNavigateToDetails: (repoId: Long) -> Unit, + viewModel: ExternalImportViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + ExternalImportEvent.NavigateBack -> onNavigateBack() + is ExternalImportEvent.NavigateToDetails -> onNavigateToDetails(event.repoId) + is ExternalImportEvent.ShowError -> { + scope.launch { snackbarHostState.showSnackbar(event.message) } + } + ExternalImportEvent.PlayConfetti -> { + // TODO confetti animation — handled in CompletionToast for now. + } + } + } + + LaunchedEffect(Unit) { + if (state.phase == ImportPhase.Idle) { + viewModel.onAction(ExternalImportAction.OnStart) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + // TODO i18n: extract to strings.xml + text = "Import installed apps", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + IconButton(onClick = { viewModel.onAction(ExternalImportAction.OnExit) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + // TODO i18n: extract to strings.xml + contentDescription = "Back", + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when (state.phase) { + ImportPhase.Idle, ImportPhase.Scanning, ImportPhase.AutoImporting -> { + ImportProgressScreen( + phase = state.phase, + totalCandidates = state.totalCandidates, + ) + } + + ImportPhase.RequestingPermission -> { + PermissionRationaleScreen( + onContinue = { + viewModel.onAction(ExternalImportAction.OnRequestPermission) + viewModel.onAction(ExternalImportAction.OnPermissionGranted) + }, + onDeny = { viewModel.onAction(ExternalImportAction.OnPermissionDenied) }, + ) + } + + ImportPhase.AwaitingReview -> { + val current = state.currentCard + if (current == null) { + EmptyStateScreen( + isPermissionDenied = state.isPermissionDenied, + onRequestPermission = { + viewModel.onAction(ExternalImportAction.OnRequestPermission) + }, + onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + ) + } else { + WizardCardStack( + cards = state.cards, + currentIndex = state.currentCardIndex, + expanded = state.currentExpanded, + searchQuery = state.searchOverrideQuery, + searchResults = state.searchOverrideResults, + isSearching = state.isSearching, + searchError = state.searchError, + onExpand = { + viewModel.onAction(ExternalImportAction.OnExpandCurrentCard) + }, + onCollapse = { + viewModel.onAction(ExternalImportAction.OnCollapseCurrentCard) + }, + onPick = { suggestion -> + viewModel.onAction(ExternalImportAction.OnPickSuggestion(suggestion)) + }, + onSkip = { + viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) + }, + onLink = { + val preselect = current.preselectedSuggestion + if (preselect != null) { + viewModel.onAction( + ExternalImportAction.OnPickSuggestion(preselect), + ) + } else { + viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) + } + }, + onSearchQueryChange = { query -> + viewModel.onAction( + ExternalImportAction.OnSearchOverrideChanged(query), + ) + }, + onSearchSubmit = { + viewModel.onAction(ExternalImportAction.OnSearchOverrideSubmit) + }, + ) + } + } + + ImportPhase.Done -> { + val tracked = state.autoImported + state.manuallyLinked + if (state.cards.isEmpty() && tracked == 0) { + EmptyStateScreen( + isPermissionDenied = state.isPermissionDenied, + onRequestPermission = { + viewModel.onAction(ExternalImportAction.OnRequestPermission) + }, + onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + ) + } else { + CompletionToast( + autoImported = state.autoImported, + manuallyLinked = state.manuallyLinked, + skipped = state.skipped, + onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + ) + } + } + } + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt new file mode 100644 index 000000000..ae6fb8bdc --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt @@ -0,0 +1,202 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlinx.collections.immutable.ImmutableList +import zed.rainxch.apps.presentation.components.InstalledAppIcon +import zed.rainxch.apps.presentation.import.model.CandidateUi +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi + +@Composable +fun CandidateCard( + candidate: CandidateUi, + expanded: Boolean, + searchQuery: String, + searchResults: ImmutableList, + isSearching: Boolean, + searchError: String?, + onExpand: () -> Unit, + onCollapse: () -> Unit, + onPick: (RepoSuggestionUi) -> Unit, + onSkip: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onSearchSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Surface( + tonalElevation = 2.dp, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + modifier = + Modifier + .fillMaxWidth() + .let { base -> + if (!expanded) base.clickable { onExpand() } else base + }, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CandidateHeader(candidate = candidate) + + if (!expanded) { + PreselectedRow(suggestion = candidate.preselectedSuggestion) + } else { + if (candidate.suggestions.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + candidate.suggestions.take(3).forEach { suggestion -> + RepoCandidateRow( + suggestion = suggestion, + onPick = onPick, + ) + } + } + } + + RepoSearchOverride( + query = searchQuery, + results = searchResults, + isSearching = isSearching, + searchError = searchError, + onQueryChange = onSearchQueryChange, + onSubmit = onSearchSubmit, + onPick = onPick, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onSkip) { + // TODO i18n: extract to strings.xml + Text("Skip") + } + IconButton(onClick = onCollapse) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + // TODO i18n: extract to strings.xml + contentDescription = "Collapse card", + ) + } + } + } + } + } + } +} + +@Composable +private fun CandidateHeader(candidate: CandidateUi) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + InstalledAppIcon( + packageName = candidate.packageName, + appName = candidate.appLabel, + modifier = + Modifier + .size(56.dp) + .clip(RoundedCornerShape(16.dp)), + ) + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = candidate.appLabel, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = candidate.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + InstallerChip(installerLabel = candidate.installerLabel) + } + } +} + +@Composable +private fun InstallerChip(installerLabel: String) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp), + ) { + Text( + // TODO i18n: extract to strings.xml + text = "Installed via $installerLabel", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } +} + +@Composable +private fun PreselectedRow(suggestion: RepoSuggestionUi?) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (suggestion == null) { + Text( + // TODO i18n: extract to strings.xml + text = "Tap to find a repo", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + ) + } else { + val percent = (suggestion.confidence * 100).roundToInt().coerceIn(0, 100) + Text( + // TODO i18n: extract to strings.xml + text = "We think this is ${suggestion.ownerSlashRepo} · $percent%", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + ) + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt new file mode 100644 index 000000000..2fa501898 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt @@ -0,0 +1,74 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun CompletionToast( + autoImported: Int, + manuallyLinked: Int, + skipped: Int, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + // TODO confetti animation + val tracked = autoImported + manuallyLinked + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(72.dp), + ) + + Text( + // TODO i18n: extract to strings.xml + text = "Now tracking $tracked apps.", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + if (skipped > 0) { + Text( + // TODO i18n: extract to strings.xml + text = "Skipped $skipped — you can re-run a scan from Settings.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + FilledTonalButton(onClick = onExit) { + // TODO i18n: extract to strings.xml + Text("View all") + } + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt new file mode 100644 index 000000000..a2ea2d0e9 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt @@ -0,0 +1,94 @@ +package zed.rainxch.apps.presentation.import.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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +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.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyStateScreen( + isPermissionDenied: Boolean, + onRequestPermission: () -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (!isPermissionDenied) { + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(72.dp), + ) + Text( + // TODO i18n: extract to strings.xml + text = "All matched.\nNothing to link.", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Button(onClick = onExit) { + // TODO i18n: extract to strings.xml + Text("Done") + } + } else { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(72.dp), + ) + Text( + // TODO i18n: extract to strings.xml + text = "Couldn't find any GitHub apps on this device.", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + // TODO i18n: extract to strings.xml + text = "This means either everything was installed via a different store, or we don't have visibility into what's installed.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = onExit) { + // TODO i18n: extract to strings.xml + Text("OK") + } + Button(onClick = onRequestPermission) { + // TODO i18n: extract to strings.xml + Text("Grant permission") + } + } + } + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt new file mode 100644 index 000000000..0e3af8da5 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt @@ -0,0 +1,67 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.TextAlign +import androidx.compose.ui.unit.dp +import zed.rainxch.apps.presentation.import.model.ImportPhase + +@Composable +fun ImportProgressScreen( + phase: ImportPhase, + totalCandidates: Int, + modifier: Modifier = Modifier, +) { + val headline = + when (phase) { + // TODO i18n: extract to strings.xml + ImportPhase.Scanning -> "Scanning your apps…" + // TODO i18n: extract to strings.xml + ImportPhase.AutoImporting -> "Importing matches…" + // TODO i18n: extract to strings.xml + else -> "Working…" + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.padding(24.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(56.dp), + strokeWidth = 4.dp, + ) + + Text( + text = headline, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Text( + // TODO i18n: extract to strings.xml + text = "Looked at $totalCandidates apps", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt new file mode 100644 index 000000000..018b70f41 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt @@ -0,0 +1,90 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun ImportProposalBanner( + pendingCount: Int, + onReview: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + tonalElevation = 2.dp, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.FileDownload, + // TODO i18n: extract to strings.xml + contentDescription = "Pending imports", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + + Spacer(Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + // TODO i18n: extract to strings.xml + text = "Found $pendingCount apps from GitHub", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + // TODO i18n: extract to strings.xml + text = "Review them to track updates here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.width(8.dp)) + + TextButton(onClick = onReview) { + Text( + // TODO i18n: extract to strings.xml + text = "Review", + ) + } + + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + // TODO i18n: extract to strings.xml + contentDescription = "Dismiss", + ) + } + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt new file mode 100644 index 000000000..ab4012515 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt @@ -0,0 +1,81 @@ +package zed.rainxch.apps.presentation.import.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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +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.TextAlign +import androidx.compose.ui.unit.dp + +private const val BODY_COPY = + "We can scan your installed apps and match them to GitHub releases — so updates and detection just work.\n\n" + + "To do that, we need to see which apps you have. Without permission we can only see about 5 apps; with it, we can see all of them.\n\n" + + "We never send the list of your apps anywhere without your permission. The match runs on your device. " + + "The optional backend lookup sends only the package name and app label of apps you ask us to match — never a full list of what's installed." + +@Composable +fun PermissionRationaleScreen( + onContinue: () -> Unit, + onDeny: () -> Unit, + modifier: Modifier = Modifier, +) { + // TODO Week 2 day 11: integrate MOKO Permissions runtime request + Box( + modifier = modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(64.dp), + ) + + Text( + // TODO i18n: extract to strings.xml + text = "Find your GitHub apps", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Text( + // TODO i18n: extract to strings.xml + text = BODY_COPY, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = onDeny) { + // TODO i18n: extract to strings.xml + Text("Not now") + } + Button(onClick = onContinue) { + // TODO i18n: extract to strings.xml + Text("Continue") + } + } + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt new file mode 100644 index 000000000..b4b3ea082 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt @@ -0,0 +1,110 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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 kotlin.math.roundToInt +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi + +@Composable +fun RepoCandidateRow( + suggestion: RepoSuggestionUi, + onPick: (RepoSuggestionUi) -> Unit, + modifier: Modifier = Modifier, +) { + val percent = (suggestion.confidence * 100).roundToInt().coerceIn(0, 100) + val (chipBg, chipFg) = + when { + suggestion.confidence >= 0.85 -> + MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer + suggestion.confidence >= 0.5 -> + MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer + else -> + MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + modifier + .fillMaxWidth() + .clickable { onPick(suggestion) } + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = suggestion.ownerSlashRepo, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!suggestion.description.isNullOrBlank()) { + Text( + text = suggestion.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (suggestion.stars != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(12.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = formatStars(suggestion.stars), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.width(12.dp)) + + Surface( + color = chipBg, + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = "$percent%", + style = MaterialTheme.typography.labelMedium, + color = chipFg, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } +} + +private fun formatStars(stars: Int): String = + when { + stars >= 1_000_000 -> "${(stars / 100_000) / 10.0}M" + stars >= 1_000 -> "${(stars / 100) / 10.0}k" + else -> stars.toString() + } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt new file mode 100644 index 000000000..17edd685e --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt @@ -0,0 +1,105 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi + +@Composable +fun RepoSearchOverride( + query: String, + results: ImmutableList, + isSearching: Boolean, + searchError: String?, + onQueryChange: (String) -> Unit, + onSubmit: () -> Unit, + onPick: (RepoSuggestionUi) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { + Text( + // TODO i18n: extract to strings.xml + text = "Not the right repo? Search…", + ) + }, + trailingIcon = { + IconButton(onClick = onSubmit) { + Icon( + imageVector = Icons.Default.Search, + // TODO i18n: extract to strings.xml + contentDescription = "Search GitHub", + ) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSubmit() }), + ) + + if (isSearching) { + CircularProgressIndicator( + modifier = + Modifier + .align(Alignment.CenterEnd) + .padding(end = 56.dp) + .size(18.dp), + strokeWidth = 2.dp, + ) + } + } + + if (!searchError.isNullOrBlank()) { + Text( + text = searchError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + if (results.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + results.forEach { suggestion -> + RepoCandidateRow( + suggestion = suggestion, + onPick = onPick, + ) + } + } + } else if (query.isNotBlank() && !isSearching) { + Text( + // TODO i18n: extract to strings.xml + text = "No matches", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt new file mode 100644 index 000000000..debc3072a --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt @@ -0,0 +1,259 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch +import zed.rainxch.apps.presentation.import.model.CandidateUi +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi + +@Composable +fun WizardCardStack( + cards: ImmutableList, + currentIndex: Int, + expanded: Boolean, + searchQuery: String, + searchResults: ImmutableList, + isSearching: Boolean, + searchError: String?, + onExpand: () -> Unit, + onCollapse: () -> Unit, + onPick: (RepoSuggestionUi) -> Unit, + onSkip: () -> Unit, + onLink: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onSearchSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + val current = cards.getOrNull(currentIndex) ?: return + val next = cards.getOrNull(currentIndex + 1) + val afterNext = cards.getOrNull(currentIndex + 2) + + Column( + modifier = modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ProgressChip( + currentIndex = currentIndex, + total = cards.size, + ) + + Box( + modifier = Modifier.fillMaxWidth().weight(1f), + contentAlignment = Alignment.TopCenter, + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() } + + if (afterNext != null) { + GhostedCard(card = afterNext, depth = 2) + } + if (next != null) { + GhostedCard(card = next, depth = 1) + } + + FrontCard( + candidate = current, + expanded = expanded, + searchQuery = searchQuery, + searchResults = searchResults, + isSearching = isSearching, + searchError = searchError, + parentWidthPx = maxWidthPx, + cardKey = currentIndex, + onExpand = onExpand, + onCollapse = onCollapse, + onPick = onPick, + onSkip = onSkip, + onLink = onLink, + onSearchQueryChange = onSearchQueryChange, + onSearchSubmit = onSearchSubmit, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onSkip, + modifier = Modifier.weight(1f), + ) { + // TODO i18n: extract to strings.xml + Text("Skip") + } + Button( + onClick = onLink, + modifier = Modifier.weight(1f), + ) { + // TODO i18n: extract to strings.xml + Text("Link") + } + } + } +} + +@Composable +private fun ProgressChip(currentIndex: Int, total: Int) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + ) { + Text( + // TODO i18n: extract to strings.xml + text = "Card ${currentIndex + 1} of $total", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} + +@Composable +private fun GhostedCard(card: CandidateUi, depth: Int) { + val (offsetDp, alpha, scale) = + when (depth) { + 1 -> Triple(8.dp, 0.7f, 0.96f) + else -> Triple(16.dp, 0.5f, 0.92f) + } + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(top = offsetDp) + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + }, + tonalElevation = 1.dp, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + // Stub content: just the app label so the ghost reads as a card behind the front one. + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = card.appLabel, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + Spacer(Modifier.height(40.dp)) + } + } +} + +@Composable +private fun FrontCard( + candidate: CandidateUi, + expanded: Boolean, + searchQuery: String, + searchResults: ImmutableList, + isSearching: Boolean, + searchError: String?, + parentWidthPx: Float, + cardKey: Int, + onExpand: () -> Unit, + onCollapse: () -> Unit, + onPick: (RepoSuggestionUi) -> Unit, + onSkip: () -> Unit, + onLink: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onSearchSubmit: () -> Unit, +) { + val offsetX = remember(cardKey) { Animatable(0f) } + val scope = rememberCoroutineScope() + val swipeThreshold = parentWidthPx * 0.25f + + // Reset offset whenever a new card slides into the front position. + LaunchedEffect(cardKey) { + offsetX.snapTo(0f) + } + + val draggable = + rememberDraggableState { delta -> + scope.launch { offsetX.snapTo(offsetX.value + delta) } + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .graphicsLayer { + translationX = offsetX.value + rotationZ = (offsetX.value / 60f).coerceIn(-12f, 12f) + } + .draggable( + state = draggable, + orientation = Orientation.Horizontal, + enabled = !expanded, + onDragStopped = { + when { + offsetX.value > swipeThreshold -> { + offsetX.animateTo(parentWidthPx, tween(200)) + onLink() + } + offsetX.value < -swipeThreshold -> { + offsetX.animateTo(-parentWidthPx, tween(200)) + onSkip() + } + else -> offsetX.animateTo(0f, tween(180)) + } + }, + ), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(20.dp), + tonalElevation = 0.dp, + ) { + CandidateCard( + candidate = candidate, + expanded = expanded, + searchQuery = searchQuery, + searchResults = searchResults, + isSearching = isSearching, + searchError = searchError, + onExpand = onExpand, + onCollapse = onCollapse, + onPick = onPick, + onSkip = onSkip, + onSearchQueryChange = onSearchQueryChange, + onSearchSubmit = onSearchSubmit, + ) + } + +} From 15e0d8a253b16c9e3f4b0cbec97d8e659c5fe15d Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 21:45:19 +0500 Subject: [PATCH 10/46] E1: wire external import route, banner, and initial scan trigger --- .../rainxch/githubstore/app/GithubStoreApp.kt | 10 ++++++++++ .../githubstore/app/navigation/AppNavigation.kt | 17 +++++++++++++++++ .../zed/rainxch/apps/presentation/AppsRoot.kt | 10 ++++++++++ 3 files changed, 37 insertions(+) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index d322ee8ed..ae4333ad5 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -17,6 +17,7 @@ import zed.rainxch.core.data.services.PackageEventReceiver import zed.rainxch.core.data.services.UpdateScheduler import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp +import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor @@ -38,6 +39,15 @@ class GithubStoreApp : Application() { startDownloadNotificationObserver() scheduleBackgroundUpdateChecks() registerSelfAsInstalledApp() + scheduleInitialExternalScan() + } + + private fun scheduleInitialExternalScan() { + appScope.launch { + runCatching { + get().scheduleInitialScanIfNeeded() + }.onFailure { Logger.w(it) { "Initial external scan scheduling failed" } } + } } private fun startDownloadNotificationObserver() { diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 8ac0ed665..e444b2d94 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -28,6 +28,7 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import zed.rainxch.apps.presentation.AppsRoot import zed.rainxch.apps.presentation.AppsViewModel +import zed.rainxch.apps.presentation.import.ExternalImportRoot import zed.rainxch.auth.presentation.AuthenticationRoot import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid @@ -316,6 +317,22 @@ fun AppNavigation( state = appsState, ) } + + composable { + ExternalImportRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + isComingFromUpdate = true, + ), + ) + }, + ) + } } val currentScreen = 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 8e93d6381..08e45b465 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 @@ -91,6 +91,7 @@ import zed.rainxch.apps.presentation.components.AdvancedAppSettingsBottomSheet import zed.rainxch.apps.presentation.components.InstalledAppIcon import zed.rainxch.apps.presentation.components.LinkAppBottomSheet import zed.rainxch.apps.presentation.components.VariantPickerDialog +import zed.rainxch.apps.presentation.import.components.ImportProposalBanner import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.AppSortRule import zed.rainxch.apps.presentation.model.UpdateAllProgress @@ -525,6 +526,15 @@ fun AppsScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (state.showImportProposalBanner) { + item(key = "external-import-banner") { + ImportProposalBanner( + pendingCount = state.pendingExternalImportCount, + onReview = { onAction(AppsAction.OnImportProposalReview) }, + onDismiss = { onAction(AppsAction.OnImportProposalDismiss) }, + ) + } + } items( items = state.filteredApps, key = { it.installedApp.packageName }, From cd12123b13ec2329059f05239cb81264ff251a4d Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 21:57:28 +0500 Subject: [PATCH 11/46] E1: fix snapshot torn writes, scan-job race, off-by-one, manual mapping --- .../core/data/network/ExternalMatchApi.kt | 2 + .../ExternalImportRepositoryImpl.kt | 92 +++++++++++-------- .../import/ExternalImportViewModel.kt | 25 +++-- .../import/model/RepoSuggestionUi.kt | 1 + 4 files changed, 72 insertions(+), 48 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt index e23e136b9..a006aba58 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt @@ -6,6 +6,7 @@ import zed.rainxch.core.data.dto.ExternalMatchResponse import zed.rainxch.core.domain.repository.TweaksRepository interface ExternalMatchApi { + // NOTE: chunking lives in the repo; impls receive whatever the repo passes suspend fun match(request: ExternalMatchRequest): Result } @@ -51,6 +52,7 @@ class ExternalMatchApiSelector( private val tweaks: TweaksRepository, ) : ExternalMatchApi { override suspend fun match(request: ExternalMatchRequest): Result = + // TODO Week 3: cache this via stateIn on a long-lived scope if (tweaks.getExternalMatchSearchEnabled().first()) { real.match(request) } else { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index c04209be4..2a58a2cb9 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import co.touchlab.kermit.Logger +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -62,7 +63,7 @@ class ExternalImportRepositoryImpl( val started = nowMillis() val granted = scanner.isPermissionGranted() val candidates = scanner.snapshot() - candidateSnapshot.value = candidates.associateBy { it.packageName } + candidateSnapshot.update { candidates.associateBy { it.packageName } } val now = nowMillis() var newCandidates = 0 @@ -169,39 +170,48 @@ class ExternalImportRepositoryImpl( override suspend fun importAutoMatched(matches: List): ImportSummary { var linked = 0 + var failed = 0 val now = nowMillis() matches.forEach { result -> val top = result.topSuggestion if (top != null && top.confidence >= AUTO_LINK_CONFIDENCE_THRESHOLD) { - val existing = externalLinkDao.get(result.packageName) - val base = existing ?: ExternalLinkEntity( - packageName = result.packageName, - state = ExternalLinkState.MATCHED.name, - repoOwner = top.owner, - repoName = top.repo, - matchSource = top.source.name.lowercase(), - matchConfidence = top.confidence, - signingFingerprint = null, - installerKind = null, - firstSeenAt = now, - lastReviewedAt = now, - skipExpiresAt = null, - ) - externalLinkDao.upsert( - base.copy( + val outcome = runCatching { + val existing = externalLinkDao.get(result.packageName) + val base = existing ?: ExternalLinkEntity( + packageName = result.packageName, state = ExternalLinkState.MATCHED.name, repoOwner = top.owner, repoName = top.repo, matchSource = top.source.name.lowercase(), matchConfidence = top.confidence, + signingFingerprint = null, + installerKind = null, + firstSeenAt = now, lastReviewedAt = now, - ), - ) - linked++ + skipExpiresAt = null, + ) + externalLinkDao.upsert( + base.copy( + state = ExternalLinkState.MATCHED.name, + repoOwner = top.owner, + repoName = top.repo, + matchSource = top.source.name.lowercase(), + matchConfidence = top.confidence, + lastReviewedAt = now, + ), + ) + } + outcome + .onSuccess { linked++ } + .onFailure { e -> + if (e is CancellationException) throw e + failed++ + Logger.w(e) { "auto-link upsert failed for ${result.packageName}" } + } } } // TODO Week 2 day 11: also call AppsRepository.linkAppToRepo to materialize installed_apps rows - return ImportSummary(attempted = matches.size, linked = linked, failed = 0) + return ImportSummary(attempted = matches.size, linked = linked, failed = failed) } override suspend fun linkManually( @@ -211,32 +221,33 @@ class ExternalImportRepositoryImpl( source: String, ): Result { val now = nowMillis() - val existing = externalLinkDao.get(packageName) - val base = existing ?: ExternalLinkEntity( - packageName = packageName, - state = ExternalLinkState.MATCHED.name, - repoOwner = owner, - repoName = repo, - matchSource = source, - matchConfidence = 1.0, - signingFingerprint = null, - installerKind = null, - firstSeenAt = now, - lastReviewedAt = now, - skipExpiresAt = null, - ) - externalLinkDao.upsert( - base.copy( + return runCatching { + val existing = externalLinkDao.get(packageName) + val base = existing ?: ExternalLinkEntity( + packageName = packageName, state = ExternalLinkState.MATCHED.name, repoOwner = owner, repoName = repo, matchSource = source, matchConfidence = 1.0, + signingFingerprint = null, + installerKind = null, + firstSeenAt = now, lastReviewedAt = now, - ), - ) + skipExpiresAt = null, + ) + externalLinkDao.upsert( + base.copy( + state = ExternalLinkState.MATCHED.name, + repoOwner = owner, + repoName = repo, + matchSource = source, + matchConfidence = 1.0, + lastReviewedAt = now, + ), + ) + }.onFailure { if (it is CancellationException) throw it } // TODO Week 2 day 11: AppsRepository.linkAppToRepo - return Result.success(Unit) } override suspend fun skipPackage( @@ -303,6 +314,7 @@ class ExternalImportRepositoryImpl( if (existing != null && shouldPreserveDecision(existing, now)) { return existing.copy( signingFingerprint = candidate.signingFingerprint ?: existing.signingFingerprint, + // installerKind is authoritative per-scan from PackageManager; signingFingerprint may briefly be null on extraction failure, so we hold the previous value. installerKind = candidate.installerKind.name, ) } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index d1eada563..abc83af55 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -31,6 +32,7 @@ class ExternalImportViewModel( private val logger: GitHubStoreLogger, ) : ViewModel() { private var hasStarted = false + private var scanJob: Job? = null private val _state = MutableStateFlow(ExternalImportState()) val state = @@ -114,10 +116,14 @@ class ExternalImportViewModel( private fun startScanIfIdle(force: Boolean = false) { if (!force && _state.value.phase != ImportPhase.Idle) return - viewModelScope.launch { + if (scanJob?.isActive == true) return + scanJob = viewModelScope.launch { try { _state.update { it.copy(phase = ImportPhase.Scanning, errorMessage = null) } + // runFullScan must precede pendingCandidatesFlow().first(): the in-memory candidate + // snapshot is process-scoped and empty on cold start, so without a scan first the + // wizard would render zero cards even with PENDING_REVIEW rows in the DAO. externalImportRepository.runFullScan() val candidates = externalImportRepository.pendingCandidatesFlow().first() @@ -262,12 +268,15 @@ class ExternalImportViewModel( val done = nextIndex >= total _state.update { current -> - transform(current).copy( - currentCardIndex = nextIndex, - currentExpanded = false, - phase = if (done) ImportPhase.Done else current.phase, - showCompletionToast = if (done) true else current.showCompletionToast, - ) + val tallied = transform(current).copy(currentExpanded = false) + if (done) { + tallied.copy( + phase = ImportPhase.Done, + showCompletionToast = true, + ) + } else { + tallied.copy(currentCardIndex = nextIndex) + } } if (done) { @@ -285,7 +294,7 @@ class ExternalImportViewModel( RepoMatchSource.MANIFEST -> SuggestionSource.MANIFEST RepoMatchSource.SEARCH -> SuggestionSource.SEARCH RepoMatchSource.FINGERPRINT -> SuggestionSource.FINGERPRINT - RepoMatchSource.MANUAL -> SuggestionSource.MANIFEST + RepoMatchSource.MANUAL -> SuggestionSource.MANUAL }, stars = stars, description = description, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt index 1fde5ebba..dc7cde52f 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt @@ -15,4 +15,5 @@ enum class SuggestionSource { MANIFEST, SEARCH, FINGERPRINT, + MANUAL, } From cb38a5ff54978f4b65ed5b8584745ac2b1629104 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 25 Apr 2026 21:57:32 +0500 Subject: [PATCH 12/46] E1: visual hierarchy, contrast, accessibility, and reduced-motion gate --- .../presentation/import/ExternalImportRoot.kt | 4 +- .../import/components/CandidateCard.kt | 134 ++++++++++-------- .../import/components/CompletionToast.kt | 4 +- .../import/components/EmptyStateScreen.kt | 4 +- .../import/components/ImportProgressScreen.kt | 4 + .../import/components/ImportProposalBanner.kt | 12 +- .../import/components/RepoCandidateRow.kt | 13 +- .../import/components/RepoSearchOverride.kt | 14 +- .../import/components/WizardCardStack.kt | 28 ++-- 9 files changed, 122 insertions(+), 95 deletions(-) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt index 94d0585a5..f01ca6f51 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -70,8 +70,8 @@ fun ExternalImportRoot( Text( // TODO i18n: extract to strings.xml text = "Import installed apps", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, ) }, navigationIcon = { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt index ae6fb8bdc..308334ea5 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt @@ -10,8 +10,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -22,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -47,72 +46,69 @@ fun CandidateCard( onSearchSubmit: () -> Unit, modifier: Modifier = Modifier, ) { - Card( - modifier = modifier.fillMaxWidth(), + Surface( + tonalElevation = 1.dp, shape = RoundedCornerShape(20.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = + modifier + .fillMaxWidth() + .let { base -> + if (!expanded) { + base.clickable( + onClickLabel = "Expand to see other matches", + role = Role.Button, + ) { onExpand() } + } else { + base + } + }, ) { - Surface( - tonalElevation = 2.dp, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - Modifier - .fillMaxWidth() - .let { base -> - if (!expanded) base.clickable { onExpand() } else base - }, + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - CandidateHeader(candidate = candidate) + CandidateHeader(candidate = candidate) - if (!expanded) { - PreselectedRow(suggestion = candidate.preselectedSuggestion) - } else { - if (candidate.suggestions.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - candidate.suggestions.take(3).forEach { suggestion -> - RepoCandidateRow( - suggestion = suggestion, - onPick = onPick, - ) - } + if (!expanded) { + PreselectedRow(suggestion = candidate.preselectedSuggestion) + } else { + if (candidate.suggestions.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + candidate.suggestions.take(3).forEach { suggestion -> + RepoCandidateRow( + suggestion = suggestion, + onPick = onPick, + ) } } + } - RepoSearchOverride( - query = searchQuery, - results = searchResults, - isSearching = isSearching, - searchError = searchError, - onQueryChange = onSearchQueryChange, - onSubmit = onSearchSubmit, - onPick = onPick, - ) + RepoSearchOverride( + query = searchQuery, + results = searchResults, + isSearching = isSearching, + searchError = searchError, + onQueryChange = onSearchQueryChange, + onSubmit = onSearchSubmit, + onPick = onPick, + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onSkip) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onSkip) { + // TODO i18n: extract to strings.xml + Text("Skip") + } + IconButton(onClick = onCollapse) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, // TODO i18n: extract to strings.xml - Text("Skip") - } - IconButton(onClick = onCollapse) { - Icon( - imageVector = Icons.Default.KeyboardArrowUp, - // TODO i18n: extract to strings.xml - contentDescription = "Collapse card", - ) - } + contentDescription = "Collapse card", + ) } } } @@ -139,7 +135,7 @@ private fun CandidateHeader(candidate: CandidateUi) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = candidate.appLabel, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -175,8 +171,20 @@ private fun InstallerChip(installerLabel: String) { @Composable private fun PreselectedRow(suggestion: RepoSuggestionUi?) { + val containerColor = + if (suggestion != null) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (suggestion != null) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } Surface( - color = MaterialTheme.colorScheme.surfaceVariant, + color = containerColor, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -185,7 +193,7 @@ private fun PreselectedRow(suggestion: RepoSuggestionUi?) { // TODO i18n: extract to strings.xml text = "Tap to find a repo", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = contentColor, modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), ) } else { @@ -194,7 +202,7 @@ private fun PreselectedRow(suggestion: RepoSuggestionUi?) { // TODO i18n: extract to strings.xml text = "We think this is ${suggestion.ownerSlashRepo} · $percent%", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + color = contentColor, modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), ) } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt index 2fa501898..d3d3de128 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -65,7 +65,7 @@ fun CompletionToast( ) } - FilledTonalButton(onClick = onExit) { + Button(onClick = onExit) { // TODO i18n: extract to strings.xml Text("View all") } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt index a2ea2d0e9..7a31a8dd1 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt @@ -41,8 +41,8 @@ fun EmptyStateScreen( Icon( imageVector = Icons.Outlined.CheckCircle, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), ) Text( // TODO i18n: extract to strings.xml diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt index 0e3af8da5..1fcc6e839 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt @@ -12,6 +12,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -53,6 +56,7 @@ fun ImportProgressScreen( fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) Text( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt index 018b70f41..f396d4542 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt @@ -32,9 +32,8 @@ fun ImportProposalBanner( ) { Surface( modifier = modifier, - tonalElevation = 2.dp, shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.secondaryContainer, ) { Row( modifier = Modifier.padding(12.dp), @@ -42,9 +41,8 @@ fun ImportProposalBanner( ) { Icon( imageVector = Icons.Outlined.FileDownload, - // TODO i18n: extract to strings.xml - contentDescription = "Pending imports", - tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(24.dp), ) @@ -59,13 +57,13 @@ fun ImportProposalBanner( text = "Found $pendingCount apps from GitHub", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSecondaryContainer, ) Text( // TODO i18n: extract to strings.xml text = "Review them to track updates here.", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSecondaryContainer, ) } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt index b4b3ea082..15ad10050 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt @@ -6,6 +6,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -19,11 +20,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlin.math.roundToInt import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi +import zed.rainxch.apps.presentation.import.model.SuggestionSource @Composable fun RepoCandidateRow( @@ -34,8 +38,10 @@ fun RepoCandidateRow( val percent = (suggestion.confidence * 100).roundToInt().coerceIn(0, 100) val (chipBg, chipFg) = when { + suggestion.source == SuggestionSource.MANUAL -> + MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant suggestion.confidence >= 0.85 -> - MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer + MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer suggestion.confidence >= 0.5 -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer else -> @@ -46,6 +52,7 @@ fun RepoCandidateRow( modifier = modifier .fillMaxWidth() + .heightIn(min = 48.dp) .clickable { onPick(suggestion) } .padding(vertical = 10.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -91,6 +98,10 @@ fun RepoCandidateRow( Surface( color = chipBg, shape = RoundedCornerShape(12.dp), + modifier = + Modifier.semantics { + contentDescription = "Match confidence: $percent percent" + }, ) { Text( text = "$percent%", diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt index 17edd685e..94f070216 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt @@ -45,6 +45,12 @@ fun RepoSearchOverride( onValueChange = onQueryChange, modifier = Modifier.fillMaxWidth(), singleLine = true, + isError = !searchError.isNullOrBlank(), + supportingText = { + if (!searchError.isNullOrBlank()) { + Text(text = searchError) + } + }, placeholder = { Text( // TODO i18n: extract to strings.xml @@ -76,14 +82,6 @@ fun RepoSearchOverride( } } - if (!searchError.isNullOrBlank()) { - Text( - text = searchError, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - if (results.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { results.forEach { suggestion -> diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt index debc3072a..e979700a2 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt @@ -23,12 +23,17 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList @@ -36,6 +41,9 @@ import kotlinx.coroutines.launch import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi +// TODO Week 3: read system reduced-motion preference and provide LocalReducedMotion +val LocalReducedMotion = compositionLocalOf { false } + @Composable fun WizardCardStack( cards: ImmutableList, @@ -131,6 +139,7 @@ private fun ProgressChip(currentIndex: Int, total: Int) { Surface( color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) { Text( // TODO i18n: extract to strings.xml @@ -145,10 +154,10 @@ private fun ProgressChip(currentIndex: Int, total: Int) { @Composable private fun GhostedCard(card: CandidateUi, depth: Int) { - val (offsetDp, alpha, scale) = + val (offsetDp, scale, ghostColor) = when (depth) { - 1 -> Triple(8.dp, 0.7f, 0.96f) - else -> Triple(16.dp, 0.5f, 0.92f) + 1 -> Triple(8.dp, 0.96f, MaterialTheme.colorScheme.surfaceContainerHigh) + else -> Triple(16.dp, 0.92f, MaterialTheme.colorScheme.surfaceContainer) } Surface( modifier = @@ -156,15 +165,13 @@ private fun GhostedCard(card: CandidateUi, depth: Int) { .fillMaxWidth() .padding(top = offsetDp) .graphicsLayer { - this.alpha = alpha scaleX = scale scaleY = scale - }, - tonalElevation = 1.dp, + } + .semantics(mergeDescendants = true) { hideFromAccessibility() }, shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, + color = ghostColor, ) { - // Stub content: just the app label so the ghost reads as a card behind the front one. Column(modifier = Modifier.padding(20.dp)) { Text( text = card.appLabel, @@ -199,8 +206,9 @@ private fun FrontCard( val offsetX = remember(cardKey) { Animatable(0f) } val scope = rememberCoroutineScope() val swipeThreshold = parentWidthPx * 0.25f + val reducedMotion = LocalReducedMotion.current + val rotationFactor = if (reducedMotion) 0f else 1f - // Reset offset whenever a new card slides into the front position. LaunchedEffect(cardKey) { offsetX.snapTo(0f) } @@ -216,7 +224,7 @@ private fun FrontCard( .fillMaxWidth() .graphicsLayer { translationX = offsetX.value - rotationZ = (offsetX.value / 60f).coerceIn(-12f, 12f) + rotationZ = (offsetX.value / 60f * rotationFactor).coerceIn(-12f, 12f) } .draggable( state = draggable, From f2a776e7fad539e71698f8df93a2e404b1c48a5b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 05:48:54 +0500 Subject: [PATCH 13/46] E1: materialize installed_apps rows on auto-link and manual link --- .../import/ExternalImportViewModel.kt | 132 +++++++++++++----- 1 file changed, 100 insertions(+), 32 deletions(-) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index abc83af55..c817a854b 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -15,11 +15,13 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import zed.rainxch.apps.domain.repository.AppsRepository import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.ImportPhase import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi import zed.rainxch.apps.presentation.import.model.SuggestionSource import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.InstallerKind @@ -29,8 +31,10 @@ import zed.rainxch.core.domain.system.RepoMatchSuggestion class ExternalImportViewModel( private val externalImportRepository: ExternalImportRepository, + private val appsRepository: AppsRepository, private val logger: GitHubStoreLogger, ) : ViewModel() { + private var candidatesByPackage: Map = emptyMap() private var hasStarted = false private var scanJob: Job? = null @@ -127,6 +131,7 @@ class ExternalImportViewModel( externalImportRepository.runFullScan() val candidates = externalImportRepository.pendingCandidatesFlow().first() + candidatesByPackage = candidates.associateBy { it.packageName } _state.update { it.copy( @@ -136,13 +141,8 @@ class ExternalImportViewModel( } val matches = externalImportRepository.resolveMatches(candidates) - val summary = externalImportRepository.importAutoMatched(matches) - - val autoLinkedPackages = - matches - .filter { it.topConfidence >= AUTO_LINK_THRESHOLD } - .map { it.packageName } - .toSet() + val autoLinked = autoMaterialize(matches) + val autoLinkedPackages = autoLinked.toSet() val reviewCandidates = candidates.filter { it.packageName !in autoLinkedPackages } @@ -162,7 +162,7 @@ class ExternalImportViewModel( phase = ImportPhase.Done, cards = persistentListOf(), currentCardIndex = 0, - autoImported = summary.linked, + autoImported = autoLinked.size, showCompletionToast = true, ) } @@ -174,7 +174,7 @@ class ExternalImportViewModel( cards = cards, currentCardIndex = 0, currentExpanded = false, - autoImported = summary.linked, + autoImported = autoLinked.size, ) } } @@ -230,38 +230,106 @@ class ExternalImportViewModel( val current = _state.value.currentCard ?: return val preselected = current.preselectedSuggestion val source = if (suggestion == preselected) "preselected" else "alternative" + val candidate = candidatesByPackage[current.packageName] viewModelScope.launch { - val result = - try { - externalImportRepository.linkManually( - packageName = current.packageName, - owner = suggestion.owner, - repo = suggestion.repo, - source = source, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Result.failure(e) - } + if (candidate == null) { + logger.error("Cannot materialize ${current.packageName}: candidate missing from snapshot") + _events.send(ExternalImportEvent.ShowError("Couldn't link this app — try again.")) + return@launch + } - if (result.isFailure) { - logger.error( - "Manual link failed for ${current.packageName}: " + - "${result.exceptionOrNull()?.message}", - ) - _events.send( - ExternalImportEvent.ShowError( - result.exceptionOrNull()?.message ?: "Link failed", - ), - ) + val materialized = materializeAndMark(candidate, suggestion.owner, suggestion.repo, source) + if (!materialized) { + _events.send(ExternalImportEvent.ShowError("Couldn't reach GitHub. Try again later.")) return@launch } advanceAfter { it.copy(manuallyLinked = it.manuallyLinked + 1) } } } + private suspend fun autoMaterialize(matches: List): List { + val linked = mutableListOf() + matches.forEach { result -> + val top = result.topSuggestion ?: return@forEach + if (top.confidence < AUTO_LINK_THRESHOLD) return@forEach + val candidate = candidatesByPackage[result.packageName] ?: return@forEach + + val ok = + materializeAndMark( + candidate = candidate, + owner = top.owner, + repo = top.repo, + source = "auto-${top.source.name.lowercase()}", + ) + if (ok) linked += result.packageName + } + return linked + } + + private suspend fun materializeAndMark( + candidate: ExternalAppCandidate, + owner: String, + repo: String, + source: String, + ): Boolean { + val repoInfo = + try { + appsRepository.fetchRepoInfo(owner, repo) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("fetchRepoInfo($owner/$repo) failed: ${e.message}") + null + } + if (repoInfo == null) { + logger.warn("Skipping link for ${candidate.packageName}: repo $owner/$repo not found") + return false + } + + val deviceApp = candidate.toDeviceApp() + try { + appsRepository.linkAppToRepo(deviceApp, repoInfo) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("linkAppToRepo failed for ${candidate.packageName}: ${e.message}") + return false + } + + val linkResult = + try { + externalImportRepository.linkManually( + packageName = candidate.packageName, + owner = owner, + repo = repo, + source = source, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + if (linkResult.isFailure) { + logger.error( + "external_links upsert failed for ${candidate.packageName}: " + + "${linkResult.exceptionOrNull()?.message}", + ) + // installed_apps row is already written; the audit trail is + // ahead but recoverable on the next scan via mergeCandidate. + } + return true + } + + private fun ExternalAppCandidate.toDeviceApp(): DeviceApp = + DeviceApp( + packageName = packageName, + appName = appLabel, + versionName = versionName, + versionCode = versionCode, + signingFingerprint = signingFingerprint, + ) + private suspend fun advanceAfter(transform: (ExternalImportState) -> ExternalImportState) { val nextIndex = _state.value.currentCardIndex + 1 val total = _state.value.cards.size From c3da019dd6e5b3235afcfdc8b5b2133d3f358d07 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 06:01:57 +0500 Subject: [PATCH 14/46] E1 week 3 data: signing-cert seed, delta scan, search, and telemetry events --- .../rainxch/githubstore/app/GithubStoreApp.kt | 15 ++ .../data/services/PackageEventReceiver.kt | 54 ++++- .../core/data/services/UpdateCheckWorker.kt | 38 +++ .../zed/rainxch/core/data/di/SharedModule.kt | 3 + .../zed/rainxch/core/data/dto/EventRequest.kt | 14 ++ .../core/data/network/BackendApiClient.kt | 22 ++ .../ExternalImportRepositoryImpl.kt | 226 +++++++++++++++++- .../repository/TelemetryRepositoryImpl.kt | 141 +++++++++++ .../repository/ExternalImportRepository.kt | 2 + .../domain/repository/TelemetryRepository.kt | 37 +++ 10 files changed, 538 insertions(+), 14 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index ae4333ad5..64fb91c5f 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext +import zed.rainxch.core.data.local.db.dao.ExternalLinkDao import zed.rainxch.core.data.services.DownloadNotificationObserver import zed.rainxch.core.data.services.PackageEventReceiver import zed.rainxch.core.data.services.UpdateScheduler @@ -40,6 +41,7 @@ class GithubStoreApp : Application() { scheduleBackgroundUpdateChecks() registerSelfAsInstalledApp() scheduleInitialExternalScan() + scheduleSigningSeedSync() } private fun scheduleInitialExternalScan() { @@ -50,6 +52,16 @@ class GithubStoreApp : Application() { } } + // Best-effort: signingFingerprintDao.lastSyncTimestamp() acts as the + // since cursor inside the repo, so repeat calls are cheap. + private fun scheduleSigningSeedSync() { + appScope.launch { + runCatching { + get().syncSigningFingerprintSeed() + }.onFailure { Logger.w(it) { "Signing seed sync failed" } } + } + } + private fun startDownloadNotificationObserver() { get().start(get()) } @@ -95,6 +107,9 @@ class GithubStoreApp : Application() { PackageEventReceiver( installedAppsRepository = get(), packageMonitor = get(), + externalImportRepository = get(), + externalLinkDao = get(), + appScope = get(), ) val filter = PackageEventReceiver.createIntentFilter() diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index bd21a1de4..789f60d71 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import zed.rainxch.core.data.local.db.dao.ExternalLinkDao +import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.util.VersionVerdict @@ -31,10 +33,15 @@ class PackageEventReceiver() : private val installedAppsRepositoryKoin: InstalledAppsRepository by inject() private val packageMonitorKoin: PackageMonitor by inject() private val appScopeKoin: CoroutineScope by inject() + private val externalImportRepositoryKoin: ExternalImportRepository by inject() + private val externalLinkDaoKoin: ExternalLinkDao by inject() // Explicitly provided dependencies (dynamic registration path) private var explicitRepository: InstalledAppsRepository? = null private var explicitMonitor: PackageMonitor? = null + private var explicitExternalImport: ExternalImportRepository? = null + private var explicitExternalLinkDao: ExternalLinkDao? = null + private var explicitAppScope: CoroutineScope? = null // Local fallback scope for the manifest-registered path when // `onReceive` fires but Koin somehow couldn't resolve the shared @@ -46,21 +53,33 @@ class PackageEventReceiver() : constructor( installedAppsRepository: InstalledAppsRepository, packageMonitor: PackageMonitor, + externalImportRepository: ExternalImportRepository, + externalLinkDao: ExternalLinkDao, + appScope: CoroutineScope, ) : this() { this.explicitRepository = installedAppsRepository this.explicitMonitor = packageMonitor + this.explicitExternalImport = externalImportRepository + this.explicitExternalLinkDao = externalLinkDao + this.explicitAppScope = appScope } private fun getRepository(): InstalledAppsRepository = explicitRepository ?: installedAppsRepositoryKoin private fun getMonitor(): PackageMonitor = explicitMonitor ?: packageMonitorKoin + private fun getExternalImport(): ExternalImportRepository = + explicitExternalImport ?: externalImportRepositoryKoin + + private fun getExternalLinkDao(): ExternalLinkDao = + explicitExternalLinkDao ?: externalLinkDaoKoin + private fun getBackstopScope(): CoroutineScope = // Koin's app-scoped CoroutineScope outlives a manifest-registered // receiver whose local `scope` would die with the instance. Fall // back to the local scope only if Koin isn't initialized yet // (shouldn't happen post-Application.onCreate, but defensive). - runCatching { appScopeKoin }.getOrElse { scope } + explicitAppScope ?: runCatching { appScopeKoin }.getOrElse { scope } override fun onReceive( context: Context?, @@ -142,6 +161,37 @@ class PackageEventReceiver() : } catch (e: Exception) { Logger.e { "PackageEventReceiver error for $packageName: ${e.message}" } } + + // Fire a delta scan for previously-untracked installs so the + // import banner can pick up the new candidate. Guarded so we + // don't churn on apps the user already linked or asked us to + // ignore. Runs on the app scope — independent of the install + // path above. + getBackstopScope().launch { + runCatching { + if (shouldRescan(packageName)) { + getExternalImport().runDeltaScan(setOf(packageName)) + } + }.onFailure { Logger.w(it) { "Delta scan failed for $packageName" } } + } + } + + // Skip re-scanning when (a) we already track the app in + // `installed_apps` (the user installed it through the store, or + // we already auto-linked it and materialized the row), or (b) the + // package is already MATCHED / NEVER_ASK in `external_links`. + // PENDING_REVIEW and SKIPPED are intentionally rescanned — + // metadata may have changed (label, fingerprint, installer) and + // the user hasn't given a permanent answer yet. + private suspend fun shouldRescan(packageName: String): Boolean { + val tracked = runCatching { getRepository().getAppByPackage(packageName) } + .getOrNull() + if (tracked != null) return false + val link = runCatching { getExternalLinkDao().get(packageName) }.getOrNull() + return when (link?.state) { + "MATCHED", "NEVER_ASK" -> false + else -> true + } } /** @@ -248,6 +298,8 @@ class PackageEventReceiver() : private suspend fun onPackageRemoved(packageName: String) { try { getRepository().deleteInstalledApp(packageName) + runCatching { getExternalImport().unlink(packageName) } + .onFailure { Logger.w(it) { "External link cleanup failed for $packageName" } } Logger.i { "Removed uninstalled app via broadcast: $packageName" } } catch (e: Exception) { Logger.e { "PackageEventReceiver remove error for $packageName: ${e.message}" } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index ab3496e3f..557c4f216 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -18,9 +18,12 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import zed.rainxch.core.data.local.db.dao.ExternalLinkDao import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase /** @@ -40,6 +43,9 @@ class UpdateCheckWorker( private val installedAppsRepository: InstalledAppsRepository by inject() private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase by inject() private val tweaksRepository: TweaksRepository by inject() + private val externalImportRepository: ExternalImportRepository by inject() + private val externalLinkDao: ExternalLinkDao by inject() + private val packageMonitor: PackageMonitor by inject() override suspend fun doWork(): Result = try { @@ -77,6 +83,8 @@ class UpdateCheckWorker( Logger.d { "UpdateCheckWorker: No updates available" } } + runPeriodicExternalDeltaScan() + Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" } Result.success() } catch (e: Exception) { @@ -88,6 +96,35 @@ class UpdateCheckWorker( } } + // Periodic best-effort: catch packages whose ACTION_PACKAGE_ADDED + // broadcast we missed (process killed, OEM app-standby, etc.). + // Cap at 50 so a 200-package device doesn't drag the worker. + private suspend fun runPeriodicExternalDeltaScan() { + try { + val installed = packageMonitor.getAllInstalledPackageNames() + if (installed.isEmpty()) return + + val trackedFlow = installedAppsRepository.getAllInstalledApps().first() + val tracked = trackedFlow.map { it.packageName }.toSet() + + val permanent = ( + externalLinkDao.getDoNotRescanPackageNames() + + externalLinkDao.getActiveSkippedPackageNames(System.currentTimeMillis()) + ).toSet() + + val delta = (installed - tracked - permanent).take(MAX_DELTA_PACKAGES).toSet() + if (delta.isEmpty()) { + Logger.d { "UpdateCheckWorker: external delta scan empty" } + return + } + + Logger.d { "UpdateCheckWorker: external delta scan ${delta.size} package(s)" } + externalImportRepository.runDeltaScan(delta) + } catch (e: Exception) { + Logger.w { "UpdateCheckWorker: external delta scan failed: ${e.message}" } + } + } + private fun createForegroundInfo(message: String): ForegroundInfo { val notification = NotificationCompat @@ -179,5 +216,6 @@ class UpdateCheckWorker( private const val UPDATE_SERVICE_CHANNEL_ID = "update_service" private const val NOTIFICATION_ID = 1001 private const val FOREGROUND_NOTIFICATION_ID = 1003 + private const val MAX_DELTA_PACKAGES = 50 } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 844ef9f2f..d20d9bad9 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -211,8 +211,11 @@ val coreModule = ExternalImportRepositoryImpl( scanner = get(), externalLinkDao = get(), + signingFingerprintDao = get(), preferences = get(), externalMatchApi = get(), + backendClient = get(), + telemetry = get(), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt index 65344a3b5..1ec3b4628 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt @@ -13,4 +13,18 @@ data class EventRequest( val resultCount: Int? = null, val success: Boolean? = null, val errorCode: String? = null, + // ── E1 external-import props (all bucketed enums or counts) ── + val trigger: String? = null, + val strategy: String? = null, + val confidenceBucket: String? = null, + val countBucket: String? = null, + val candidateCountBucket: String? = null, + val durationMsBucket: String? = null, + val rowsAddedBucket: String? = null, + val statusCodeBucket: String? = null, + val sdkIntBucket: String? = null, + val source: String? = null, + val persisted: String? = null, + val granted: Boolean? = null, + val retried: Boolean? = null, ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index 4c9fc2f92..3ac89899b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -37,6 +37,7 @@ import zed.rainxch.core.data.dto.ExternalMatchRequest import zed.rainxch.core.data.dto.ExternalMatchResponse import zed.rainxch.core.data.dto.GithubReadmeResponseDto import zed.rainxch.core.data.dto.ReleaseNetwork +import zed.rainxch.core.data.dto.SigningFingerprintSeedResponse import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.domain.model.ProxyConfig import kotlin.coroutines.cancellation.CancellationException @@ -259,6 +260,27 @@ class BackendApiClient( } } + suspend fun getSigningSeeds( + since: Long? = null, + cursor: String? = null, + platform: String = "android", + ): Result = + safeCall { + val response = httpClient.get("signing-seeds") { + parameter("platform", platform) + if (since != null) parameter("since", since) + if (cursor != null) parameter("cursor", cursor) + } + when { + response.status.isSuccess() -> + Result.success(response.body()) + response.status == HttpStatusCode.TooManyRequests -> + Result.failure(RateLimitedException()) + else -> + Result.failure(BackendException(response.status.value)) + } + } + suspend fun postEvents(events: List): Result = safeCall { val response = httpClient.post("events") { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 2a58a2cb9..ae3afaca3 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -14,11 +14,17 @@ import kotlinx.coroutines.flow.update import kotlin.time.Clock import zed.rainxch.core.data.dto.ExternalMatchRequest import zed.rainxch.core.data.local.db.dao.ExternalLinkDao +import zed.rainxch.core.data.local.db.dao.SigningFingerprintDao import zed.rainxch.core.data.local.db.entities.ExternalLinkEntity +import zed.rainxch.core.data.local.db.entities.SigningFingerprintEntity import zed.rainxch.core.data.mappers.toRepoMatchResults import zed.rainxch.core.data.mappers.toRequestItem +import zed.rainxch.core.data.network.BackendApiClient +import zed.rainxch.core.data.network.BackendException import zed.rainxch.core.data.network.ExternalMatchApi +import zed.rainxch.core.data.network.RateLimitedException import zed.rainxch.core.domain.repository.ExternalImportRepository +import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.ExternalLinkState @@ -31,8 +37,11 @@ import zed.rainxch.core.domain.system.ScanResult class ExternalImportRepositoryImpl( private val scanner: ExternalAppScanner, private val externalLinkDao: ExternalLinkDao, + private val signingFingerprintDao: SigningFingerprintDao, private val preferences: DataStore, private val externalMatchApi: ExternalMatchApi, + private val backendClient: BackendApiClient, + private val telemetry: TelemetryRepository, ) : ExternalImportRepository { // Snapshot cache survives only for the lifetime of the process. Decisions // (linked / skipped / never-ask) are persisted in `external_links`; the @@ -54,8 +63,11 @@ class ExternalImportRepositoryImpl( override suspend fun scheduleInitialScanIfNeeded() { val alreadyScanned = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] != null if (alreadyScanned) return - runCatching { runFullScan() } - .onSuccess { markInitialScanComplete() } + runCatching { + runCatching { telemetry.importScanStarted(trigger = "first_launch") } + .onFailure { Logger.d { "telemetry importScanStarted failed: ${it.message}" } } + runFullScan() + }.onSuccess { markInitialScanComplete() } .onFailure { Logger.w(it) { "Initial external scan failed; will retry on next launch." } } } @@ -77,12 +89,20 @@ class ExternalImportRepositoryImpl( externalLinkDao.upsert(updated) } + val durationMs = nowMillis() - started + runCatching { + telemetry.importScanCompleted( + candidateCountBucket = bucketCandidateCount(candidates.size), + durationMsBucket = bucketDurationMs(durationMs), + ) + }.onFailure { Logger.d { "telemetry importScanCompleted failed: ${it.message}" } } + return ScanResult( totalCandidates = candidates.size, newCandidates = newCandidates, autoLinked = 0, // wired with backend match resolver in Week 2 pendingReview = pendingReview, - durationMillis = nowMillis() - started, + durationMillis = durationMs, permissionGranted = granted, ) } @@ -130,6 +150,23 @@ class ExternalImportRepositoryImpl( override suspend fun resolveMatches(candidates: List): List { if (candidates.isEmpty()) return emptyList() + // Strategy 3: signing-fingerprint lookup against the local seed + // table. Hits are the strongest non-manifest signal we have — + // signature equality is cryptographic, no string fuzzing. + val fingerprintHits = mutableMapOf() + candidates.forEach { candidate -> + val fp = candidate.signingFingerprint ?: return@forEach + val hit = runCatching { signingFingerprintDao.lookup(fp) } + .onFailure { Logger.d { "signing fingerprint lookup failed: ${it.message}" } } + .getOrNull() ?: return@forEach + fingerprintHits[candidate.packageName] = RepoMatchSuggestion( + owner = hit.repoOwner, + repo = hit.repoName, + confidence = FINGERPRINT_CONFIDENCE, + source = RepoMatchSource.FINGERPRINT, + ) + } + val backendResults = mutableMapOf>() for (batch in candidates.chunked(MATCH_BATCH_SIZE)) { val request = @@ -145,7 +182,15 @@ class ExternalImportRepositoryImpl( .getOrPut(result.packageName) { mutableListOf() } .addAll(result.suggestions) } - }.onFailure { Logger.w(it) { "external-match batch failed; continuing" } } + }.onFailure { error -> + Logger.w(error) { "external-match batch failed; continuing" } + runCatching { + telemetry.externalMatchApiFailure( + statusCodeBucket = bucketApiFailure(error), + retried = false, + ) + }.onFailure { Logger.d { "telemetry externalMatchApiFailure failed: ${it.message}" } } + } } return candidates.map { candidate -> @@ -158,13 +203,26 @@ class ExternalImportRepositoryImpl( source = RepoMatchSource.MANIFEST, ) } + fingerprintHits[candidate.packageName]?.let { suggestions += it } backendResults[candidate.packageName]?.let { suggestions += it } - RepoMatchResult( - packageName = candidate.packageName, - suggestions = suggestions - .distinctBy { "${it.owner}/${it.repo}" } - .sortedByDescending { it.confidence }, - ) + val deduped = suggestions + .distinctBy { "${it.owner}/${it.repo}" } + .sortedByDescending { it.confidence } + + // Emit one `import_match_attempted` per strategy that + // produced a hit for this candidate. Bucketed confidence + // only — never owner/repo/package name. + deduped.groupBy { it.source }.forEach { (source, hits) -> + val top = hits.maxByOrNull { it.confidence } ?: return@forEach + runCatching { + telemetry.importMatchAttempted( + strategy = source.telemetryStrategy(), + confidenceBucket = bucketConfidence(top.confidence), + ) + }.onFailure { Logger.d { "telemetry importMatchAttempted failed: ${it.message}" } } + } + + RepoMatchResult(packageName = candidate.packageName, suggestions = deduped) } } @@ -211,6 +269,8 @@ class ExternalImportRepositoryImpl( } } // TODO Week 2 day 11: also call AppsRepository.linkAppToRepo to materialize installed_apps rows + runCatching { telemetry.importAutoLinked(countBucket = bucketCount(linked)) } + .onFailure { Logger.d { "telemetry importAutoLinked failed: ${it.message}" } } return ImportSummary(attempted = matches.size, linked = linked, failed = failed) } @@ -290,8 +350,86 @@ class ExternalImportRepositoryImpl( return resolveMatches(listOf(candidate)).firstOrNull() } + override suspend fun searchRepos(query: String): Result> { + val trimmed = query.trim() + if (trimmed.isEmpty()) return Result.success(emptyList()) + val capped = if (trimmed.length > MAX_SEARCH_QUERY_LEN) { + trimmed.substring(0, MAX_SEARCH_QUERY_LEN) + } else { + trimmed + } + return backendClient + .search(query = capped, platform = "android", limit = SEARCH_LIMIT) + .map { response -> + response.items.map { item -> + RepoMatchSuggestion( + owner = item.owner.login, + repo = item.name, + // Search is the user-driven override path. The + // 0.5 confidence is a placeholder — UX is "I'll + // pick this myself", not a confidence bet. + confidence = SEARCH_OVERRIDE_CONFIDENCE, + source = RepoMatchSource.SEARCH, + stars = item.stargazersCount, + description = item.description, + ) + } + } + } + override suspend fun syncSigningFingerprintSeed() { - notImplemented("syncSigningFingerprintSeed") + val started = nowMillis() + var rowsAdded = 0 + try { + val lastObservedAt = runCatching { signingFingerprintDao.lastSyncTimestamp() } + .getOrNull() + var cursor: String? = null + var pages = 0 + paging@ while (pages < MAX_SEED_PAGES) { + pages++ + val pageResult = backendClient.getSigningSeeds( + since = lastObservedAt, + cursor = cursor, + ) + val response = pageResult.getOrElse { error -> + if (error is CancellationException) throw error + Logger.w(error) { "signing-seeds fetch failed on page $pages; aborting" } + break@paging + } + val rows = response.rows.map { row -> + SigningFingerprintEntity( + fingerprint = row.fingerprint, + repoOwner = row.owner, + repoName = row.repo, + source = SEED_SOURCE_BACKEND, + observedAt = row.observedAt, + ) + } + if (rows.isNotEmpty()) { + runCatching { signingFingerprintDao.upsertAll(rows) } + .onSuccess { rowsAdded += rows.size } + .onFailure { e -> + if (e is CancellationException) throw e + Logger.w(e) { "signing-seeds upsert failed on page $pages; continuing" } + } + } + cursor = response.nextCursor ?: break@paging + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e) { "signing-seeds sync aborted" } + } + emitSeedSyncTelemetry(rowsAdded, nowMillis() - started) + } + + private suspend fun emitSeedSyncTelemetry(rowsAdded: Int, durationMs: Long) { + runCatching { + telemetry.signingSeedSyncCompleted( + rowsAddedBucket = bucketSeedRowsAdded(rowsAdded), + durationMsBucket = bucketDurationMs(durationMs), + ) + }.onFailure { Logger.d { "telemetry signingSeedSyncCompleted failed: ${it.message}" } } } override suspend fun pruneExpiredSkips() { @@ -348,13 +486,75 @@ class ExternalImportRepositoryImpl( private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() - private fun notImplemented(name: String): Nothing = - error("ExternalImportRepository.$name is not implemented yet (Week 2/3 of E1).") + private fun bucketCount(n: Int): String = + when { + n <= 0 -> "0" + n <= 2 -> "1-2" + n <= 9 -> "3-9" + n <= 49 -> "10-49" + else -> "50+" + } + + private fun bucketCandidateCount(n: Int): String = + when { + n <= 0 -> "0" + n <= 9 -> "1-9" + n <= 49 -> "10-49" + n <= 199 -> "50-199" + else -> "200+" + } + + private fun bucketDurationMs(ms: Long): String = + when { + ms < 500L -> "<500" + ms < 2_000L -> "500-2000" + ms < 5_000L -> "2000-5000" + else -> ">5000" + } + + private fun bucketConfidence(c: Double): String = + when { + c < 0.5 -> "<0.5" + c < 0.85 -> "0.5-0.85" + else -> ">=0.85" + } + + private fun bucketSeedRowsAdded(n: Int): String = + when { + n <= 0 -> "0" + n <= 99 -> "1-99" + n <= 999 -> "100-999" + else -> "1000+" + } + + private fun bucketApiFailure(error: Throwable): String = + when (error) { + is BackendException -> { + val code = error.statusCode + if (code in 400..499) "4xx" else "5xx" + } + is RateLimitedException -> "4xx" + else -> "network" + } + + private fun RepoMatchSource.telemetryStrategy(): String = + when (this) { + RepoMatchSource.MANIFEST -> "manifest" + RepoMatchSource.SEARCH -> "search" + RepoMatchSource.FINGERPRINT -> "fingerprint" + RepoMatchSource.MANUAL -> "manual" + } companion object { private val INITIAL_SCAN_COMPLETED_AT_KEY = longPreferencesKey("external_import_initial_scan_at") private const val SKIP_TTL_MILLIS: Long = 7L * 24 * 60 * 60 * 1000 private const val MATCH_BATCH_SIZE = 25 private const val AUTO_LINK_CONFIDENCE_THRESHOLD = 0.85 + private const val FINGERPRINT_CONFIDENCE = 0.92 + private const val SEARCH_OVERRIDE_CONFIDENCE = 0.5 + private const val SEARCH_LIMIT = 10 + private const val MAX_SEARCH_QUERY_LEN = 100 + private const val MAX_SEED_PAGES = 50 + private const val SEED_SOURCE_BACKEND = "backend_seed" } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt index 4b96a055f..a36428895 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt @@ -95,6 +95,97 @@ class TelemetryRepositoryImpl( enqueue(eventType = "unfavorited", repoId = repoId) } + // ── E1 external-import events ─────────────────────────────────── + // Privacy invariant: never pass package names, repo names, app + // labels, or signing fingerprints — only bucketed strings, enums, + // and counts. Enforced in CI by `PrivacyAuditTest` (E6). + + override suspend fun importScanStarted(trigger: String) { + enqueueExt(eventType = "import_scan_started", trigger = trigger) + } + + override suspend fun importScanCompleted( + candidateCountBucket: String, + durationMsBucket: String, + ) { + enqueueExt( + eventType = "import_scan_completed", + candidateCountBucket = candidateCountBucket, + durationMsBucket = durationMsBucket, + ) + } + + override suspend fun importMatchAttempted(strategy: String, confidenceBucket: String) { + enqueueExt( + eventType = "import_match_attempted", + strategy = strategy, + confidenceBucket = confidenceBucket, + ) + } + + override suspend fun importAutoLinked(countBucket: String) { + enqueueExt(eventType = "import_auto_linked", countBucket = countBucket) + } + + override suspend fun importManuallyLinked(countBucket: String, source: String) { + enqueueExt( + eventType = "import_manually_linked", + countBucket = countBucket, + source = source, + ) + } + + override suspend fun importSkipped(countBucket: String, persisted: String) { + enqueueExt( + eventType = "import_skipped", + countBucket = countBucket, + persisted = persisted, + ) + } + + override suspend fun importUnlinkedFromDetails() { + enqueueExt(eventType = "import_unlinked_from_details") + } + + override suspend fun importPermissionRequested() { + enqueueExt(eventType = "import_permission_requested") + } + + override suspend fun importPermissionOutcome(granted: Boolean, sdkIntBucket: String) { + enqueueExt( + eventType = "import_permission_outcome", + granted = granted, + sdkIntBucket = sdkIntBucket, + ) + } + + override suspend fun importSearchOverrideUsed() { + enqueueExt(eventType = "import_search_override_used") + } + + override suspend fun importSearchOverrideNoResults() { + enqueueExt(eventType = "import_search_override_no_results") + } + + override suspend fun signingSeedSyncCompleted( + rowsAddedBucket: String, + durationMsBucket: String, + ) { + enqueueExt( + eventType = "signing_seed_sync_completed", + rowsAddedBucket = rowsAddedBucket, + durationMsBucket = durationMsBucket, + ) + } + + override suspend fun externalMatchApiFailure(statusCodeBucket: String, retried: Boolean) { + enqueueExt( + eventType = "external_match_api_failure", + statusCodeBucket = statusCodeBucket, + retried = retried, + ) + } + // ── batching ──────────────────────────────────────────────────── override suspend fun flushPending() { @@ -180,6 +271,56 @@ class TelemetryRepositoryImpl( } } + private fun enqueueExt( + eventType: String, + trigger: String? = null, + strategy: String? = null, + confidenceBucket: String? = null, + countBucket: String? = null, + candidateCountBucket: String? = null, + durationMsBucket: String? = null, + rowsAddedBucket: String? = null, + statusCodeBucket: String? = null, + sdkIntBucket: String? = null, + source: String? = null, + persisted: String? = null, + granted: Boolean? = null, + retried: Boolean? = null, + ) { + appScope.launch { + if (!telemetryEnabled()) return@launch + + val deviceId = runCatching { deviceIdentity.getDeviceId() }.getOrNull() ?: return@launch + + val event = EventRequest( + deviceId = deviceId, + platform = platformSlug(), + appVersion = BuildKonfig.VERSION_NAME, + eventType = eventType, + trigger = trigger, + strategy = strategy, + confidenceBucket = confidenceBucket, + countBucket = countBucket, + candidateCountBucket = candidateCountBucket, + durationMsBucket = durationMsBucket, + rowsAddedBucket = rowsAddedBucket, + statusCodeBucket = statusCodeBucket, + sdkIntBucket = sdkIntBucket, + source = source, + persisted = persisted, + granted = granted, + retried = retried, + ) + + bufferMutex.withLock { + if (buffer.size >= MAX_BUFFER_SIZE) { + buffer.removeFirst() + } + buffer.add(event) + } + } + } + private fun platformSlug(): String = when (platform) { Platform.ANDROID -> "android" Platform.MACOS -> "desktop-macos" diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt index af9b6584d..c86f48e69 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt @@ -37,6 +37,8 @@ interface ExternalImportRepository { suspend fun rescanSinglePackage(packageName: String): RepoMatchResult? + suspend fun searchRepos(query: String): Result> + suspend fun syncSigningFingerprintSeed() suspend fun pruneExpiredSkips() diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt index 8a9d42a0a..e40ed7dec 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt @@ -23,6 +23,43 @@ interface TelemetryRepository { fun recordUnfavorited(repoId: Long) + // ── E1 external-import telemetry ──────────────────────────────── + // All payloads are bucketed/enum strings — never package names, + // repo names, app labels, or fingerprints. See E1 plan §8. + + suspend fun importScanStarted(trigger: String) + + suspend fun importScanCompleted(candidateCountBucket: String, durationMsBucket: String) + + suspend fun importMatchAttempted(strategy: String, confidenceBucket: String) + + suspend fun importAutoLinked(countBucket: String) + + // TODO Week 3 (Agent 2): wire from ExternalImportViewModel + suspend fun importManuallyLinked(countBucket: String, source: String) + + // TODO Week 3 (Agent 2): wire from ExternalImportViewModel + suspend fun importSkipped(countBucket: String, persisted: String) + + // TODO Week 3 (Agent 2): wire from Details screen Unlink affordance + suspend fun importUnlinkedFromDetails() + + // TODO Week 3 (Agent 2): wire from ExternalImportViewModel + suspend fun importPermissionRequested() + + // TODO Week 3 (Agent 2): wire from ExternalImportViewModel + suspend fun importPermissionOutcome(granted: Boolean, sdkIntBucket: String) + + // TODO Week 3 (Agent 2): wire from ExternalImportViewModel + suspend fun importSearchOverrideUsed() + + // TODO Week 3 (Agent 2): wire from ExternalImportViewModel + suspend fun importSearchOverrideNoResults() + + suspend fun signingSeedSyncCompleted(rowsAddedBucket: String, durationMsBucket: String) + + suspend fun externalMatchApiFailure(statusCodeBucket: String, retried: Boolean) + suspend fun flushPending() /** From 195578bce7c5d9cec6530b76bd80a2c5134b4fe2 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 06:02:07 +0500 Subject: [PATCH 15/46] E1 week 3 wizard: search override, reduced-motion preference --- .../util/ReducedMotionProvider.android.kt | 23 +++++++ .../presentation/import/ExternalImportRoot.kt | 7 ++ .../import/ExternalImportViewModel.kt | 64 ++++++++++++++++--- .../import/components/WizardCardStack.kt | 5 +- .../presentation/import/util/ReducedMotion.kt | 5 ++ .../import/util/ReducedMotionProvider.kt | 6 ++ .../import/util/ReducedMotionProvider.jvm.kt | 9 +++ 7 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotion.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.kt create mode 100644 feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt new file mode 100644 index 000000000..d2e071c65 --- /dev/null +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt @@ -0,0 +1,23 @@ +package zed.rainxch.apps.presentation.import.util + +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberSystemReducedMotion(): Boolean { + val context = LocalContext.current + // Reading once at composition is fine — ANIMATOR_DURATION_SCALE is a + // global system setting that almost never flips while the wizard is + // on screen. If it does, the next composition (rotation, navigation) + // will pick up the new value. + return remember(context) { + val scale = Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + scale == 0f + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt index f01ca6f51..4a77adc27 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -32,6 +33,8 @@ import zed.rainxch.apps.presentation.import.components.ImportProgressScreen import zed.rainxch.apps.presentation.import.components.PermissionRationaleScreen import zed.rainxch.apps.presentation.import.components.WizardCardStack import zed.rainxch.apps.presentation.import.model.ImportPhase +import zed.rainxch.apps.presentation.import.util.LocalReducedMotion +import zed.rainxch.apps.presentation.import.util.rememberSystemReducedMotion import zed.rainxch.core.presentation.utils.ObserveAsEvents @Composable @@ -63,6 +66,9 @@ fun ExternalImportRoot( } } + val reducedMotion = rememberSystemReducedMotion() + + CompositionLocalProvider(LocalReducedMotion provides reducedMotion) { Scaffold( topBar = { TopAppBar( @@ -181,4 +187,5 @@ fun ExternalImportRoot( } } } + } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index c817a854b..c61ab8f15 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -37,6 +37,7 @@ class ExternalImportViewModel( private var candidatesByPackage: Map = emptyMap() private var hasStarted = false private var scanJob: Job? = null + private var searchJob: Job? = null private val _state = MutableStateFlow(ExternalImportState()) val state = @@ -90,19 +91,13 @@ class ExternalImportViewModel( } is ExternalImportAction.OnSearchOverrideChanged -> { + // Explicit submit only: typing alone never fires a request, + // both because the existing UX expects an Enter/icon tap and + // to avoid hammering the rate-limited backend search. _state.update { it.copy(searchOverrideQuery = action.query) } } - ExternalImportAction.OnSearchOverrideSubmit -> { - // TODO Week 3: wire to BackendApiClient.search - _state.update { it.copy(isSearching = true) } - _state.update { - it.copy( - isSearching = false, - searchOverrideResults = persistentListOf(), - ) - } - } + ExternalImportAction.OnSearchOverrideSubmit -> submitSearchOverride() ExternalImportAction.OnUndoLast -> Unit @@ -212,6 +207,55 @@ class ExternalImportViewModel( ) } + private fun submitSearchOverride() { + val query = _state.value.searchOverrideQuery.trim() + if (query.isEmpty()) { + searchJob?.cancel() + _state.update { + it.copy( + isSearching = false, + searchError = null, + searchOverrideResults = persistentListOf(), + ) + } + return + } + + searchJob?.cancel() + _state.update { it.copy(isSearching = true, searchError = null) } + searchJob = viewModelScope.launch { + val result = runCatching { externalImportRepository.searchRepos(query) } + .getOrElse { e -> + if (e is CancellationException) throw e + Result.failure(e) + } + + result.fold( + onSuccess = { suggestions -> + _state.update { + it.copy( + isSearching = false, + searchError = null, + searchOverrideResults = + suggestions.map { s -> s.toUi() }.toImmutableList(), + ) + } + }, + onFailure = { e -> + if (e is CancellationException) throw e + logger.error("Search override failed for '$query': ${e.message}") + _state.update { + it.copy( + isSearching = false, + searchError = e.message ?: "Search failed", + searchOverrideResults = persistentListOf(), + ) + } + }, + ) + } + } + private fun skipCurrent(neverAsk: Boolean) { val current = _state.value.currentCard ?: return viewModelScope.launch { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt index e979700a2..eb5aabb8e 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -40,9 +39,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi - -// TODO Week 3: read system reduced-motion preference and provide LocalReducedMotion -val LocalReducedMotion = compositionLocalOf { false } +import zed.rainxch.apps.presentation.import.util.LocalReducedMotion @Composable fun WizardCardStack( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotion.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotion.kt new file mode 100644 index 000000000..0602574b1 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotion.kt @@ -0,0 +1,5 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.compositionLocalOf + +val LocalReducedMotion = compositionLocalOf { false } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.kt new file mode 100644 index 000000000..2a027bb63 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.kt @@ -0,0 +1,6 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberSystemReducedMotion(): Boolean diff --git a/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt new file mode 100644 index 000000000..80f2be7e1 --- /dev/null +++ b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt @@ -0,0 +1,9 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.Composable + +// Desktop has no equivalent system "remove animations" toggle that the +// JVM Compose target can read portably. The wizard isn't shown on +// Desktop today (E2 territory), so this is a safe default. +@Composable +actual fun rememberSystemReducedMotion(): Boolean = false From 6b55e5664e1a20cd5b3e1c5fcbbc48c4c6d615da Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 06:02:19 +0500 Subject: [PATCH 16/46] E1 week 3 details: unlink external app affordance with confirmation --- .../githubstore/app/di/ViewModelsModule.kt | 1 + .../details/presentation/DetailsAction.kt | 4 + .../details/presentation/DetailsRoot.kt | 95 +++++++++++++++++++ .../details/presentation/DetailsState.kt | 1 + .../details/presentation/DetailsViewModel.kt | 44 +++++++++ 5 files changed, 145 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt index 55ee6ae76..4539c3ce5 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt @@ -50,6 +50,7 @@ val viewModelsModule = attestationVerifier = get(), downloadOrchestrator = get(), telemetryRepository = get(), + externalImportRepository = get(), ) } viewModelOf(::DeveloperProfileViewModel) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 5d5a9bc26..eccf0c8ea 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -24,6 +24,10 @@ sealed interface DetailsAction { data object OnDismissUninstallConfirmation : DetailsAction data object OnConfirmUninstall : DetailsAction + data object OnUnlinkExternalApp : DetailsAction + data object OnDismissUnlinkConfirmation : DetailsAction + data object OnConfirmUnlinkExternalApp : DetailsAction + data class DownloadAsset( val downloadUrl: String, val assetName: String, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index 04dcb9b58..8c74ae110 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -19,11 +19,15 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -41,8 +45,10 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow @@ -59,6 +65,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.presentation.components.ScrollbarContainer import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.core.presentation.theme.GithubStoreTheme @@ -288,6 +295,49 @@ fun DetailsRoot( ) } + if (state.showUnlinkConfirmation) { + val appName = state.installedApp?.appName ?: "" + AlertDialog( + onDismissRequest = { + viewModel.onAction(DetailsAction.OnDismissUnlinkConfirmation) + }, + title = { + // TODO i18n: extract to strings.xml + Text(text = "Unlink this app?") + }, + text = { + // TODO i18n: extract to strings.xml + Text( + text = + "We'll stop tracking $appName as installed from this repo. " + + "The app stays on your device — only the link is removed.", + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.onAction(DetailsAction.OnConfirmUnlinkExternalApp) + }, + ) { + // TODO i18n: extract to strings.xml + Text( + text = "Unlink", + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.onAction(DetailsAction.OnDismissUnlinkConfirmation) + }, + ) { + Text(text = stringResource(Res.string.cancel)) + } + }, + ) + } + if (state.showExternalInstallerPrompt) { AlertDialog( onDismissRequest = { @@ -695,6 +745,51 @@ private fun DetailsTopbar( ) } } + + // External-import unlink lives in an overflow menu so it's + // discoverable without taking up scarce topbar space — and + // only appears for MANUAL-tagged installs (the install + // source the wizard / manual link sets). + if (state.installedApp?.installSource == InstallSource.MANUAL) { + var menuOpen by remember { mutableStateOf(false) } + Box { + IconButton( + shapes = IconButtonDefaults.shapes(), + onClick = { menuOpen = true }, + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + // TODO i18n: extract to strings.xml + contentDescription = "More options", + ) + } + DropdownMenu( + expanded = menuOpen, + onDismissRequest = { menuOpen = false }, + ) { + DropdownMenuItem( + text = { + // TODO i18n: extract to strings.xml + Text(text = "Unlink from this repo") + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.LinkOff, + contentDescription = null, + ) + }, + onClick = { + menuOpen = false + onAction(DetailsAction.OnUnlinkExternalApp) + }, + ) + } + } + } } }, colors = diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index 6cc7ca83e..907543ffd 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -69,6 +69,7 @@ data class DetailsState( val showExternalInstallerPrompt: Boolean = false, val pendingInstallFilePath: String? = null, val showUninstallConfirmation: Boolean = false, + val showUnlinkConfirmation: Boolean = false, val attestationStatus: AttestationStatus = AttestationStatus.UNCHECKED, /** * Days since the most recent stable release when the project is diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index adce81161..a36f155c4 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -31,6 +31,7 @@ import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.core.domain.network.Downloader +import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository @@ -114,6 +115,7 @@ class DetailsViewModel( private val attestationVerifier: AttestationVerifier, private val downloadOrchestrator: DownloadOrchestrator, private val telemetryRepository: TelemetryRepository, + private val externalImportRepository: ExternalImportRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var currentDownloadJob: Job? = null @@ -161,6 +163,36 @@ class DetailsViewModel( } } + private fun confirmUnlinkExternalApp() { + _state.update { it.copy(showUnlinkConfirmation = false) } + val installedApp = _state.value.installedApp ?: return + val packageName = installedApp.packageName + logger.debug("Unlinking externally-imported app: $packageName") + viewModelScope.launch { + try { + // installed_apps + external_links must move together so the + // next scan re-proposes a match instead of treating the row + // as a healthy tracked app on a stale link. + installedAppsRepository.executeInTransaction { + externalImportRepository.unlink(packageName) + installedAppsRepository.deleteInstalledApp(packageName) + } + // TODO i18n: extract to strings.xml + _events.send( + DetailsEvent.OnMessage("Unlinked. We'll re-suggest a match next scan."), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Failed to unlink $packageName: ${e.message}") + // TODO i18n: extract to strings.xml + _events.send( + DetailsEvent.OnMessage("Couldn't unlink — try again."), + ) + } + } + } + @OptIn(ExperimentalTime::class) fun onAction(action: DetailsAction) { when (action) { @@ -209,6 +241,18 @@ class DetailsViewModel( uninstallApp() } + DetailsAction.OnUnlinkExternalApp -> { + _state.update { it.copy(showUnlinkConfirmation = true) } + } + + DetailsAction.OnDismissUnlinkConfirmation -> { + _state.update { it.copy(showUnlinkConfirmation = false) } + } + + DetailsAction.OnConfirmUnlinkExternalApp -> { + confirmUnlinkExternalApp() + } + is DetailsAction.DownloadAsset -> { val release = _state.value.selectedRelease downloadAsset( From ff99c1e2fdf01da44852a9b6fcc5145a675a88d5 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 06:04:49 +0500 Subject: [PATCH 17/46] E1 week 3: wire telemetry from view models for permission, link, skip, search, unlink --- .../import/ExternalImportViewModel.kt | 29 +++++++++++++++++++ .../details/presentation/DetailsViewModel.kt | 1 + 2 files changed, 30 insertions(+) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index c61ab8f15..e7955834c 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -23,6 +23,7 @@ import zed.rainxch.apps.presentation.import.model.SuggestionSource import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.repository.ExternalImportRepository +import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.InstallerKind import zed.rainxch.core.domain.system.RepoMatchResult @@ -32,6 +33,7 @@ import zed.rainxch.core.domain.system.RepoMatchSuggestion class ExternalImportViewModel( private val externalImportRepository: ExternalImportRepository, private val appsRepository: AppsRepository, + private val telemetry: TelemetryRepository, private val logger: GitHubStoreLogger, ) : ViewModel() { private var candidatesByPackage: Map = emptyMap() @@ -64,15 +66,18 @@ class ExternalImportViewModel( ExternalImportAction.OnRequestPermission -> { _state.update { it.copy(phase = ImportPhase.RequestingPermission) } + viewModelScope.launch { runCatching { telemetry.importPermissionRequested() } } } ExternalImportAction.OnPermissionGranted -> { _state.update { it.copy(isPermissionDenied = false) } + emitPermissionOutcome(granted = true) startScanIfIdle(force = true) } ExternalImportAction.OnPermissionDenied -> { _state.update { it.copy(isPermissionDenied = true) } + emitPermissionOutcome(granted = false) startScanIfIdle(force = true) } @@ -223,6 +228,7 @@ class ExternalImportViewModel( searchJob?.cancel() _state.update { it.copy(isSearching = true, searchError = null) } + viewModelScope.launch { runCatching { telemetry.importSearchOverrideUsed() } } searchJob = viewModelScope.launch { val result = runCatching { externalImportRepository.searchRepos(query) } .getOrElse { e -> @@ -232,6 +238,9 @@ class ExternalImportViewModel( result.fold( onSuccess = { suggestions -> + if (suggestions.isEmpty()) { + runCatching { telemetry.importSearchOverrideNoResults() } + } _state.update { it.copy( isSearching = false, @@ -266,6 +275,12 @@ class ExternalImportViewModel( } catch (e: Exception) { logger.error("Skip failed for ${current.packageName}: ${e.message}") } + runCatching { + telemetry.importSkipped( + countBucket = "1-2", + persisted = if (neverAsk) "forever" else "7day", + ) + } advanceAfter { it.copy(skipped = it.skipped + 1) } } } @@ -288,10 +303,24 @@ class ExternalImportViewModel( _events.send(ExternalImportEvent.ShowError("Couldn't reach GitHub. Try again later.")) return@launch } + runCatching { + telemetry.importManuallyLinked(countBucket = "1-2", source = source) + } advanceAfter { it.copy(manuallyLinked = it.manuallyLinked + 1) } } } + private fun emitPermissionOutcome(granted: Boolean) { + viewModelScope.launch { + runCatching { + telemetry.importPermissionOutcome( + granted = granted, + sdkIntBucket = "unknown", + ) + } + } + } + private suspend fun autoMaterialize(matches: List): List { val linked = mutableListOf() matches.forEach { result -> diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index a36f155c4..1045dc3ba 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -177,6 +177,7 @@ class DetailsViewModel( externalImportRepository.unlink(packageName) installedAppsRepository.deleteInstalledApp(packageName) } + runCatching { telemetryRepository.importUnlinkedFromDetails() } // TODO i18n: extract to strings.xml _events.send( DetailsEvent.OnMessage("Unlinked. We'll re-suggest a match next scan."), From c0a178383f5363295f311554749a06d4f0aec562 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 15:05:52 +0500 Subject: [PATCH 18/46] E1: cache external-match flag, drop banner threshold to 1, prune stale TODOs --- .../zed/rainxch/core/data/di/SharedModule.kt | 1 + .../rainxch/core/data/network/ExternalMatchApi.kt | 15 +++++++++++---- .../repository/ExternalImportRepositoryImpl.kt | 2 -- .../core/domain/repository/TelemetryRepository.kt | 7 ------- .../rainxch/apps/presentation/AppsViewModel.kt | 3 ++- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index d20d9bad9..8b2e5247b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -204,6 +204,7 @@ val coreModule = real = get(), mock = get(), tweaks = get(), + scope = get(), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt index a006aba58..72f354ec9 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt @@ -1,6 +1,8 @@ package zed.rainxch.core.data.network -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import zed.rainxch.core.data.dto.ExternalMatchRequest import zed.rainxch.core.data.dto.ExternalMatchResponse import zed.rainxch.core.domain.repository.TweaksRepository @@ -49,11 +51,16 @@ class MockExternalMatchApi : ExternalMatchApi { class ExternalMatchApiSelector( private val real: BackendExternalMatchApi, private val mock: MockExternalMatchApi, - private val tweaks: TweaksRepository, + tweaks: TweaksRepository, + scope: CoroutineScope, ) : ExternalMatchApi { + // Cache the flag in a hot StateFlow so `match()` can read it + // synchronously instead of round-tripping DataStore on every call. + private val flagState = tweaks.getExternalMatchSearchEnabled() + .stateIn(scope, SharingStarted.Eagerly, initialValue = false) + override suspend fun match(request: ExternalMatchRequest): Result = - // TODO Week 3: cache this via stateIn on a long-lived scope - if (tweaks.getExternalMatchSearchEnabled().first()) { + if (flagState.value) { real.match(request) } else { mock.match(request) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index ae3afaca3..2f5997b19 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -268,7 +268,6 @@ class ExternalImportRepositoryImpl( } } } - // TODO Week 2 day 11: also call AppsRepository.linkAppToRepo to materialize installed_apps rows runCatching { telemetry.importAutoLinked(countBucket = bucketCount(linked)) } .onFailure { Logger.d { "telemetry importAutoLinked failed: ${it.message}" } } return ImportSummary(attempted = matches.size, linked = linked, failed = failed) @@ -307,7 +306,6 @@ class ExternalImportRepositoryImpl( ), ) }.onFailure { if (it is CancellationException) throw it } - // TODO Week 2 day 11: AppsRepository.linkAppToRepo } override suspend fun skipPackage( diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt index e40ed7dec..d5e394d95 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt @@ -35,25 +35,18 @@ interface TelemetryRepository { suspend fun importAutoLinked(countBucket: String) - // TODO Week 3 (Agent 2): wire from ExternalImportViewModel suspend fun importManuallyLinked(countBucket: String, source: String) - // TODO Week 3 (Agent 2): wire from ExternalImportViewModel suspend fun importSkipped(countBucket: String, persisted: String) - // TODO Week 3 (Agent 2): wire from Details screen Unlink affordance suspend fun importUnlinkedFromDetails() - // TODO Week 3 (Agent 2): wire from ExternalImportViewModel suspend fun importPermissionRequested() - // TODO Week 3 (Agent 2): wire from ExternalImportViewModel suspend fun importPermissionOutcome(granted: Boolean, sdkIntBucket: String) - // TODO Week 3 (Agent 2): wire from ExternalImportViewModel suspend fun importSearchOverrideUsed() - // TODO Week 3 (Agent 2): wire from ExternalImportViewModel suspend fun importSearchOverrideNoResults() suspend fun signingSeedSyncCompleted(rowsAddedBucket: String, durationMsBucket: String) 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 dfed57987..6e1f10a71 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 @@ -62,6 +62,7 @@ class AppsViewModel( private val externalImportRepository: ExternalImportRepository, ) : ViewModel() { companion object { + private const val BANNER_THRESHOLD = 1 private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L } @@ -107,7 +108,7 @@ class AppsViewModel( _state.update { it.copy( pendingExternalImportCount = count, - showImportProposalBanner = count >= 3 && !it.isExternalImportInFlight, + showImportProposalBanner = count >= BANNER_THRESHOLD && !it.isExternalImportInFlight, ) } } From 465064988a41cbd1eb614b3d908c9b706d4bfaa2 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 15:06:05 +0500 Subject: [PATCH 19/46] E1: sdk-int telemetry, package-visibility request, confetti, skip-remaining --- .../PackageVisibilityRequester.android.kt | 48 ++++ .../import/util/SdkLevelProvider.android.kt | 7 + .../import/ExternalImportAction.kt | 6 +- .../presentation/import/ExternalImportRoot.kt | 250 ++++++++++-------- .../import/ExternalImportViewModel.kt | 69 ++++- .../import/components/CompletionToast.kt | 1 - .../import/components/ConfettiOverlay.kt | 111 ++++++++ .../components/PermissionRationaleScreen.kt | 32 ++- .../import/util/PackageVisibilityRequester.kt | 11 + .../import/util/SdkLevelProvider.kt | 6 + .../util/PackageVisibilityRequester.jvm.kt | 13 + .../import/util/SdkLevelProvider.jvm.kt | 6 + 12 files changed, 439 insertions(+), 121 deletions(-) create mode 100644 feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt create mode 100644 feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.android.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.kt create mode 100644 feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt create mode 100644 feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.jvm.kt diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt new file mode 100644 index 000000000..9d2234052 --- /dev/null +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt @@ -0,0 +1,48 @@ +package zed.rainxch.apps.presentation.import.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +// keep in sync with AndroidExternalAppScanner.GRANT_THRESHOLD +private const val GRANT_THRESHOLD = 30 + +@Composable +actual fun rememberPackageVisibilityRequester(): PackageVisibilityRequester { + val context = LocalContext.current.applicationContext + return remember(context) { AndroidPackageVisibilityRequester(context) } +} + +private class AndroidPackageVisibilityRequester( + private val context: Context, +) : PackageVisibilityRequester { + override val isGranted: Boolean + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return true + val pm = context.packageManager + val visible = runCatching { pm.getInstalledPackages(0) }.getOrElse { emptyList() } + return visible.size >= GRANT_THRESHOLD + } + + override suspend fun requestOrOpenSettings(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return true + // QUERY_ALL_PACKAGES is a "special app access" permission as of API 30 — there + // is no native runtime dialog. Best we can do is land the user on the App Info + // page where the toggle lives. We can't observe grant from here; the caller + // re-checks `isGranted` after the user returns. + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { context.startActivity(intent) } + return false + } +} diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.android.kt new file mode 100644 index 000000000..4c7c9f604 --- /dev/null +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.android.kt @@ -0,0 +1,7 @@ +package zed.rainxch.apps.presentation.import.util + +import android.os.Build +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberSdkInt(): Int? = Build.VERSION.SDK_INT diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt index c60e4b899..f16413374 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt @@ -7,14 +7,16 @@ sealed interface ExternalImportAction { data object OnRequestPermission : ExternalImportAction - data object OnPermissionGranted : ExternalImportAction + data class OnPermissionGranted(val sdkInt: Int?) : ExternalImportAction - data object OnPermissionDenied : ExternalImportAction + data class OnPermissionDenied(val sdkInt: Int?) : ExternalImportAction data object OnSkipCurrentCard : ExternalImportAction data object OnSkipForever : ExternalImportAction + data object OnSkipRemaining : ExternalImportAction + data class OnPickSuggestion(val suggestion: RepoSuggestionUi) : ExternalImportAction data object OnExpandCurrentCard : ExternalImportAction diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt index 4a77adc27..d7b8a7272 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -20,14 +23,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.apps.presentation.import.components.CompletionToast +import zed.rainxch.apps.presentation.import.components.ConfettiOverlay import zed.rainxch.apps.presentation.import.components.EmptyStateScreen import zed.rainxch.apps.presentation.import.components.ImportProgressScreen import zed.rainxch.apps.presentation.import.components.PermissionRationaleScreen @@ -46,6 +52,7 @@ fun ExternalImportRoot( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + var confettiTrigger by remember { mutableStateOf(0) } ObserveAsEvents(viewModel.events) { event -> when (event) { @@ -54,9 +61,7 @@ fun ExternalImportRoot( is ExternalImportEvent.ShowError -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } - ExternalImportEvent.PlayConfetti -> { - // TODO confetti animation — handled in CompletionToast for now. - } + ExternalImportEvent.PlayConfetti -> confettiTrigger++ } } @@ -69,123 +74,154 @@ fun ExternalImportRoot( val reducedMotion = rememberSystemReducedMotion() CompositionLocalProvider(LocalReducedMotion provides reducedMotion) { - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - // TODO i18n: extract to strings.xml - text = "Import installed apps", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - }, - navigationIcon = { - IconButton(onClick = { viewModel.onAction(ExternalImportAction.OnExit) }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + Scaffold( + topBar = { + TopAppBar( + title = { + Text( // TODO i18n: extract to strings.xml - contentDescription = "Back", + text = "Import installed apps", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = { viewModel.onAction(ExternalImportAction.OnExit) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + // TODO i18n: extract to strings.xml + contentDescription = "Back", + ) + } + }, + actions = { + if (state.phase == ImportPhase.AwaitingReview && state.cardsRemaining > 1) { + var menuOpen by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + // TODO i18n: extract to strings.xml + contentDescription = "More options", + ) + } + DropdownMenu( + expanded = menuOpen, + onDismissRequest = { menuOpen = false }, + ) { + DropdownMenuItem( + // TODO i18n: extract to strings.xml + text = { Text("Skip remaining") }, + onClick = { + menuOpen = false + viewModel.onAction(ExternalImportAction.OnSkipRemaining) + }, + ) + } + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when (state.phase) { + ImportPhase.Idle, ImportPhase.Scanning, ImportPhase.AutoImporting -> { + ImportProgressScreen( + phase = state.phase, + totalCandidates = state.totalCandidates, ) } - }, - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> - Box(modifier = Modifier.fillMaxSize().padding(padding)) { - when (state.phase) { - ImportPhase.Idle, ImportPhase.Scanning, ImportPhase.AutoImporting -> { - ImportProgressScreen( - phase = state.phase, - totalCandidates = state.totalCandidates, - ) - } - ImportPhase.RequestingPermission -> { - PermissionRationaleScreen( - onContinue = { - viewModel.onAction(ExternalImportAction.OnRequestPermission) - viewModel.onAction(ExternalImportAction.OnPermissionGranted) - }, - onDeny = { viewModel.onAction(ExternalImportAction.OnPermissionDenied) }, - ) - } - - ImportPhase.AwaitingReview -> { - val current = state.currentCard - if (current == null) { - EmptyStateScreen( - isPermissionDenied = state.isPermissionDenied, - onRequestPermission = { - viewModel.onAction(ExternalImportAction.OnRequestPermission) - }, - onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + ImportPhase.RequestingPermission -> { + PermissionRationaleScreen( + onAction = viewModel::onAction, ) - } else { - WizardCardStack( - cards = state.cards, - currentIndex = state.currentCardIndex, - expanded = state.currentExpanded, - searchQuery = state.searchOverrideQuery, - searchResults = state.searchOverrideResults, - isSearching = state.isSearching, - searchError = state.searchError, - onExpand = { - viewModel.onAction(ExternalImportAction.OnExpandCurrentCard) - }, - onCollapse = { - viewModel.onAction(ExternalImportAction.OnCollapseCurrentCard) - }, - onPick = { suggestion -> - viewModel.onAction(ExternalImportAction.OnPickSuggestion(suggestion)) - }, - onSkip = { - viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) - }, - onLink = { - val preselect = current.preselectedSuggestion - if (preselect != null) { + } + + ImportPhase.AwaitingReview -> { + val current = state.currentCard + if (current == null) { + EmptyStateScreen( + isPermissionDenied = state.isPermissionDenied, + onRequestPermission = { + viewModel.onAction(ExternalImportAction.OnRequestPermission) + }, + onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + ) + } else { + WizardCardStack( + cards = state.cards, + currentIndex = state.currentCardIndex, + expanded = state.currentExpanded, + searchQuery = state.searchOverrideQuery, + searchResults = state.searchOverrideResults, + isSearching = state.isSearching, + searchError = state.searchError, + onExpand = { + viewModel.onAction(ExternalImportAction.OnExpandCurrentCard) + }, + onCollapse = { + viewModel.onAction(ExternalImportAction.OnCollapseCurrentCard) + }, + onPick = { suggestion -> + viewModel.onAction(ExternalImportAction.OnPickSuggestion(suggestion)) + }, + onSkip = { + viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) + }, + onLink = { + val preselect = current.preselectedSuggestion + if (preselect != null) { + viewModel.onAction( + ExternalImportAction.OnPickSuggestion(preselect), + ) + } else { + viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) + } + }, + onSearchQueryChange = { query -> viewModel.onAction( - ExternalImportAction.OnPickSuggestion(preselect), + ExternalImportAction.OnSearchOverrideChanged(query), ) - } else { - viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) - } - }, - onSearchQueryChange = { query -> - viewModel.onAction( - ExternalImportAction.OnSearchOverrideChanged(query), - ) - }, - onSearchSubmit = { - viewModel.onAction(ExternalImportAction.OnSearchOverrideSubmit) - }, - ) + }, + onSearchSubmit = { + viewModel.onAction(ExternalImportAction.OnSearchOverrideSubmit) + }, + ) + } + } + + ImportPhase.Done -> { + val tracked = state.autoImported + state.manuallyLinked + if (state.cards.isEmpty() && tracked == 0) { + EmptyStateScreen( + isPermissionDenied = state.isPermissionDenied, + onRequestPermission = { + viewModel.onAction(ExternalImportAction.OnRequestPermission) + }, + onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + ) + } else { + CompletionToast( + autoImported = state.autoImported, + manuallyLinked = state.manuallyLinked, + skipped = state.skipped, + onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + ) + } } } - ImportPhase.Done -> { - val tracked = state.autoImported + state.manuallyLinked - if (state.cards.isEmpty() && tracked == 0) { - EmptyStateScreen( - isPermissionDenied = state.isPermissionDenied, - onRequestPermission = { - viewModel.onAction(ExternalImportAction.OnRequestPermission) - }, - onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, - ) - } else { - CompletionToast( - autoImported = state.autoImported, - manuallyLinked = state.manuallyLinked, - skipped = state.skipped, - onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, - ) + // Confetti is gated on PlayConfetti events: each event bumps the trigger + // and remounts the overlay so its LaunchedEffect re-runs the burst. + if (state.phase == ImportPhase.Done && confettiTrigger > 0) { + androidx.compose.runtime.key(confettiTrigger) { + ConfettiOverlay(enabled = true) } } } } } - } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index e7955834c..c2015eef5 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -69,15 +69,15 @@ class ExternalImportViewModel( viewModelScope.launch { runCatching { telemetry.importPermissionRequested() } } } - ExternalImportAction.OnPermissionGranted -> { + is ExternalImportAction.OnPermissionGranted -> { _state.update { it.copy(isPermissionDenied = false) } - emitPermissionOutcome(granted = true) + emitPermissionOutcome(granted = true, sdkInt = action.sdkInt) startScanIfIdle(force = true) } - ExternalImportAction.OnPermissionDenied -> { + is ExternalImportAction.OnPermissionDenied -> { _state.update { it.copy(isPermissionDenied = true) } - emitPermissionOutcome(granted = false) + emitPermissionOutcome(granted = false, sdkInt = action.sdkInt) startScanIfIdle(force = true) } @@ -85,6 +85,8 @@ class ExternalImportViewModel( ExternalImportAction.OnSkipForever -> skipCurrent(neverAsk = true) + ExternalImportAction.OnSkipRemaining -> skipRemaining() + is ExternalImportAction.OnPickSuggestion -> pickSuggestion(action.suggestion) ExternalImportAction.OnExpandCurrentCard -> { @@ -310,17 +312,72 @@ class ExternalImportViewModel( } } - private fun emitPermissionOutcome(granted: Boolean) { + private fun emitPermissionOutcome(granted: Boolean, sdkInt: Int?) { viewModelScope.launch { runCatching { telemetry.importPermissionOutcome( granted = granted, - sdkIntBucket = "unknown", + sdkIntBucket = bucketSdkInt(sdkInt), ) } } } + private fun bucketSdkInt(sdkInt: Int?): String = + when { + sdkInt == null -> "unknown" + sdkInt in 26..29 -> "26-29" + sdkInt in 30..32 -> "30-32" + sdkInt >= 33 -> "33+" + else -> "unknown" + } + + private fun bucketCount(count: Int): String = + when { + count <= 0 -> "0" + count in 1..2 -> "1-2" + count in 3..9 -> "3-9" + count in 10..49 -> "10-49" + else -> "50+" + } + + private fun skipRemaining() { + val current = _state.value + if (current.phase != ImportPhase.AwaitingReview) return + val remaining = current.cards.drop(current.currentCardIndex) + if (remaining.isEmpty()) return + + viewModelScope.launch { + remaining.forEach { card -> + try { + externalImportRepository.skipPackage(card.packageName, neverAsk = false) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Skip-remaining failed for ${card.packageName}: ${e.message}") + } + } + + runCatching { + telemetry.importSkipped( + countBucket = bucketCount(remaining.size), + persisted = "7day", + ) + } + + _state.update { + it.copy( + currentCardIndex = it.cards.size, + currentExpanded = false, + phase = ImportPhase.Done, + skipped = it.skipped + remaining.size, + showCompletionToast = true, + ) + } + _events.send(ExternalImportEvent.PlayConfetti) + } + } + private suspend fun autoMaterialize(matches: List): List { val linked = mutableListOf() matches.forEach { result -> diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt index d3d3de128..3c05c023f 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt @@ -27,7 +27,6 @@ fun CompletionToast( onExit: () -> Unit, modifier: Modifier = Modifier, ) { - // TODO confetti animation val tracked = autoImported + manuallyLinked Box( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt new file mode 100644 index 000000000..952eaeb6d --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt @@ -0,0 +1,111 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.zIndex +import kotlin.math.min +import kotlin.random.Random +import zed.rainxch.apps.presentation.import.util.LocalReducedMotion + +private data class Particle( + val xFraction: Float, + val fallSpeed: Float, + val rotationSpeed: Float, + val rotationOffset: Float, + val radiusPx: Float, + val color: Color, +) + +@Composable +fun ConfettiOverlay( + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val reducedMotion = LocalReducedMotion.current + if (!enabled || reducedMotion) return + + val palette = listOf( + androidx.compose.material3.MaterialTheme.colorScheme.primary, + androidx.compose.material3.MaterialTheme.colorScheme.secondary, + androidx.compose.material3.MaterialTheme.colorScheme.tertiary, + androidx.compose.material3.MaterialTheme.colorScheme.primaryContainer, + androidx.compose.material3.MaterialTheme.colorScheme.error, + ) + + val particles = remember(palette) { + val rng = Random(42) + List(40) { index -> + // Use error sparingly — every 7th particle, and primary/secondary/tertiary + // for the rest with primaryContainer mixed in for variety. + val color = when { + index % 7 == 6 -> palette[4] + index % 5 == 0 -> palette[3] + else -> palette[index % 3] + } + Particle( + xFraction = rng.nextFloat(), + fallSpeed = 0.7f + rng.nextFloat() * 0.6f, + rotationSpeed = (rng.nextFloat() - 0.5f) * 360f, + rotationOffset = rng.nextFloat() * 360f, + radiusPx = 6f + rng.nextFloat() * 6f, + color = color, + ) + } + } + + val progress = remember { Animatable(0f) } + var size by remember { mutableStateOf(IntSize.Zero) } + + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 1500, easing = LinearEasing), + ) + } + + if (progress.value >= 1f) return + + Canvas( + modifier = modifier + .fillMaxSize() + .zIndex(1f) + .onSizeChanged { size = it }, + ) { + if (size.width == 0 || size.height == 0) return@Canvas + val width = size.width.toFloat() + val height = size.height.toFloat() + val travel = height + 200f + val alpha = (1f - progress.value).coerceIn(0f, 1f) + + particles.forEach { p -> + val x = p.xFraction * width + val y = -40f + travel * p.fallSpeed * progress.value + // Stop drawing once a particle has cleared the bottom. + if (y > height + p.radiusPx) return@forEach + + val rotation = p.rotationOffset + p.rotationSpeed * progress.value + rotate(degrees = rotation, pivot = Offset(x, y)) { + drawCircle( + color = p.color.copy(alpha = min(1f, alpha) * p.color.alpha), + radius = p.radiusPx, + center = Offset(x, y), + ) + } + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt index ab4012515..243353018 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt @@ -15,11 +15,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import zed.rainxch.apps.presentation.import.ExternalImportAction +import zed.rainxch.apps.presentation.import.util.rememberPackageVisibilityRequester +import zed.rainxch.apps.presentation.import.util.rememberSdkInt private const val BODY_COPY = "We can scan your installed apps and match them to GitHub releases — so updates and detection just work.\n\n" + @@ -29,11 +34,13 @@ private const val BODY_COPY = @Composable fun PermissionRationaleScreen( - onContinue: () -> Unit, - onDeny: () -> Unit, + onAction: (ExternalImportAction) -> Unit, modifier: Modifier = Modifier, ) { - // TODO Week 2 day 11: integrate MOKO Permissions runtime request + val sdkInt = rememberSdkInt() + val requester = rememberPackageVisibilityRequester() + val scope = rememberCoroutineScope() + Box( modifier = modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center, @@ -67,11 +74,26 @@ fun PermissionRationaleScreen( ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton(onClick = onDeny) { + OutlinedButton( + onClick = { onAction(ExternalImportAction.OnPermissionDenied(sdkInt)) }, + ) { // TODO i18n: extract to strings.xml Text("Not now") } - Button(onClick = onContinue) { + Button(onClick = { + scope.launch { + onAction(ExternalImportAction.OnRequestPermission) + if (requester.isGranted) { + onAction(ExternalImportAction.OnPermissionGranted(sdkInt)) + } else { + requester.requestOrOpenSettings() + // We can't auto-confirm grant from a settings deep-link. + // Optimistically advance — the scanner's degraded path + // handles the actual visibility outcome. + onAction(ExternalImportAction.OnPermissionGranted(sdkInt)) + } + } + }) { // TODO i18n: extract to strings.xml Text("Continue") } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt new file mode 100644 index 000000000..f8d210a2b --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt @@ -0,0 +1,11 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberPackageVisibilityRequester(): PackageVisibilityRequester + +interface PackageVisibilityRequester { + val isGranted: Boolean + suspend fun requestOrOpenSettings(): Boolean +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.kt new file mode 100644 index 000000000..e1a442edc --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.kt @@ -0,0 +1,6 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberSdkInt(): Int? diff --git a/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt new file mode 100644 index 000000000..f3d2f3a5c --- /dev/null +++ b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt @@ -0,0 +1,13 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +actual fun rememberPackageVisibilityRequester(): PackageVisibilityRequester = + remember { JvmPackageVisibilityRequester } + +private object JvmPackageVisibilityRequester : PackageVisibilityRequester { + override val isGranted: Boolean = true + override suspend fun requestOrOpenSettings(): Boolean = true +} diff --git a/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.jvm.kt b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.jvm.kt new file mode 100644 index 000000000..be8325839 --- /dev/null +++ b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/SdkLevelProvider.jvm.kt @@ -0,0 +1,6 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberSdkInt(): Int? = null From c7e4a2fd22e5d930d34ade0622919105dedaa36f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 15:17:24 +0500 Subject: [PATCH 20/46] E1: extract wizard, banner, and unlink strings to compose-resources catalog --- .../composeResources/values/strings.xml | 69 +++++++++++++++++++ .../presentation/import/ExternalImportRoot.kt | 18 ++--- .../import/ExternalImportViewModel.kt | 51 ++++++++++---- .../import/components/CandidateCard.kt | 31 ++++++--- .../import/components/CompletionToast.kt | 20 ++++-- .../import/components/EmptyStateScreen.kt | 26 +++---- .../import/components/ImportProgressScreen.kt | 24 ++++--- .../import/components/ImportProposalBanner.kt | 24 ++++--- .../components/PermissionRationaleScreen.kt | 24 +++---- .../import/components/RepoCandidateRow.kt | 10 ++- .../import/components/RepoSearchOverride.kt | 14 ++-- .../import/components/WizardCardStack.kt | 19 +++-- .../details/presentation/DetailsRoot.kt | 22 +++--- .../details/presentation/DetailsViewModel.kt | 12 ++-- 14 files changed, 254 insertions(+), 110 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index dc535c5b8..bd2c2f153 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -768,4 +768,73 @@ App Secret Save credentials Youdao credentials saved + + + Import installed apps + Back + More options + Skip remaining + Scanning your apps… + Importing matches… + Working… + + Looked at %1$d app + Looked at %1$d apps + + Skip + Link + Card %1$d of %2$d + Installed via %1$s + We think this is %1$s · %2$d%% + Tap to find a repo + Expand to see other matches + Collapse card + Not the right repo? Search… + Search GitHub + No matches + Search failed + + Now tracking %1$d app. + Now tracking %1$d apps. + + Skipped %1$d — you can re-run a scan from Settings. + View all + All matched.\nNothing to link. + Done + Couldn\'t find any GitHub apps on this device. + This means either everything was installed via a different store, or we don\'t have visibility into what\'s installed. + Grant permission + OK + Find your GitHub apps + + We can scan your installed apps and match them to GitHub releases — so updates and detection just work.\n\nTo do that, we need to see which apps you have. Without permission we can only see about 5 apps; with it, we can see all of them.\n\nWe never send the list of your apps anywhere without your permission. The match runs on your device. The optional backend lookup sends only the package name and app label of apps you ask us to match — never a full list of what\'s installed. + Continue + Not now + + Found %1$d app from GitHub + Found %1$d apps from GitHub + + Review them to track updates here. + Review + Dismiss + Match confidence: %1$d percent + %1$d%% + Couldn\'t link this app — try again. + Couldn\'t reach GitHub. Try again later. + Scan failed + Obtainium + F-Droid + Browser + Sideload + GitHub Store + Unknown source + + + Unlink from this repo + More options + Unlink this app? + We\'ll stop tracking %1$s as installed from this repo. The app stays on your device — only the link is removed. + Unlink + Unlinked. We\'ll re-suggest a match next scan. + Couldn\'t unlink — try again. diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt index d7b8a7272..1ff126cfe 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.apps.presentation.import.components.CompletionToast import zed.rainxch.apps.presentation.import.components.ConfettiOverlay @@ -42,6 +43,11 @@ import zed.rainxch.apps.presentation.import.model.ImportPhase import zed.rainxch.apps.presentation.import.util.LocalReducedMotion import zed.rainxch.apps.presentation.import.util.rememberSystemReducedMotion import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_overflow_more +import zed.rainxch.githubstore.core.presentation.res.external_import_overflow_skip_remaining +import zed.rainxch.githubstore.core.presentation.res.external_import_top_bar_back +import zed.rainxch.githubstore.core.presentation.res.external_import_top_bar_title @Composable fun ExternalImportRoot( @@ -79,8 +85,7 @@ fun ExternalImportRoot( TopAppBar( title = { Text( - // TODO i18n: extract to strings.xml - text = "Import installed apps", + text = stringResource(Res.string.external_import_top_bar_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) @@ -89,8 +94,7 @@ fun ExternalImportRoot( IconButton(onClick = { viewModel.onAction(ExternalImportAction.OnExit) }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - // TODO i18n: extract to strings.xml - contentDescription = "Back", + contentDescription = stringResource(Res.string.external_import_top_bar_back), ) } }, @@ -101,8 +105,7 @@ fun ExternalImportRoot( IconButton(onClick = { menuOpen = true }) { Icon( imageVector = Icons.Outlined.MoreVert, - // TODO i18n: extract to strings.xml - contentDescription = "More options", + contentDescription = stringResource(Res.string.external_import_overflow_more), ) } DropdownMenu( @@ -110,8 +113,7 @@ fun ExternalImportRoot( onDismissRequest = { menuOpen = false }, ) { DropdownMenuItem( - // TODO i18n: extract to strings.xml - text = { Text("Skip remaining") }, + text = { Text(stringResource(Res.string.external_import_overflow_skip_remaining)) }, onClick = { menuOpen = false viewModel.onAction(ExternalImportAction.OnSkipRemaining) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index c2015eef5..3ba93a93c 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString import zed.rainxch.apps.domain.repository.AppsRepository import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.ImportPhase @@ -29,6 +30,17 @@ import zed.rainxch.core.domain.system.InstallerKind import zed.rainxch.core.domain.system.RepoMatchResult import zed.rainxch.core.domain.system.RepoMatchSource import zed.rainxch.core.domain.system.RepoMatchSuggestion +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_error_link_failed +import zed.rainxch.githubstore.core.presentation.res.external_import_error_link_network +import zed.rainxch.githubstore.core.presentation.res.external_import_error_scan_failed_default +import zed.rainxch.githubstore.core.presentation.res.external_import_installer_browser +import zed.rainxch.githubstore.core.presentation.res.external_import_installer_fdroid +import zed.rainxch.githubstore.core.presentation.res.external_import_installer_obtainium +import zed.rainxch.githubstore.core.presentation.res.external_import_installer_self +import zed.rainxch.githubstore.core.presentation.res.external_import_installer_sideload +import zed.rainxch.githubstore.core.presentation.res.external_import_installer_unknown +import zed.rainxch.githubstore.core.presentation.res.external_import_search_error_default class ExternalImportViewModel( private val externalImportRepository: ExternalImportRepository, @@ -190,12 +202,16 @@ class ExternalImportViewModel( errorMessage = e.message, ) } - _events.send(ExternalImportEvent.ShowError(e.message ?: "Scan failed")) + _events.send( + ExternalImportEvent.ShowError( + e.message ?: getString(Res.string.external_import_error_scan_failed_default), + ), + ) } } } - private fun buildCard( + private suspend fun buildCard( candidate: ExternalAppCandidate, match: RepoMatchResult?, ): CandidateUi? { @@ -255,10 +271,11 @@ class ExternalImportViewModel( onFailure = { e -> if (e is CancellationException) throw e logger.error("Search override failed for '$query': ${e.message}") + val fallback = getString(Res.string.external_import_search_error_default) _state.update { it.copy( isSearching = false, - searchError = e.message ?: "Search failed", + searchError = e.message ?: fallback, searchOverrideResults = persistentListOf(), ) } @@ -296,13 +313,21 @@ class ExternalImportViewModel( viewModelScope.launch { if (candidate == null) { logger.error("Cannot materialize ${current.packageName}: candidate missing from snapshot") - _events.send(ExternalImportEvent.ShowError("Couldn't link this app — try again.")) + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_failed), + ), + ) return@launch } val materialized = materializeAndMark(candidate, suggestion.owner, suggestion.repo, source) if (!materialized) { - _events.send(ExternalImportEvent.ShowError("Couldn't reach GitHub. Try again later.")) + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_network), + ), + ) return@launch } runCatching { @@ -498,15 +523,15 @@ class ExternalImportViewModel( description = description, ) - private fun InstallerKind.toUiLabel(): String = + private suspend fun InstallerKind.toUiLabel(): String = when (this) { - InstallerKind.STORE_OBTAINIUM -> "Obtainium" - InstallerKind.STORE_FDROID -> "F-Droid" - InstallerKind.BROWSER -> "Browser" - InstallerKind.SIDELOAD -> "Sideload" - InstallerKind.GITHUB_STORE_SELF -> "GitHub Store" - InstallerKind.UNKNOWN -> "Unknown source" - else -> "Unknown source" + InstallerKind.STORE_OBTAINIUM -> getString(Res.string.external_import_installer_obtainium) + InstallerKind.STORE_FDROID -> getString(Res.string.external_import_installer_fdroid) + InstallerKind.BROWSER -> getString(Res.string.external_import_installer_browser) + InstallerKind.SIDELOAD -> getString(Res.string.external_import_installer_sideload) + InstallerKind.GITHUB_STORE_SELF -> getString(Res.string.external_import_installer_self) + InstallerKind.UNKNOWN -> getString(Res.string.external_import_installer_unknown) + else -> getString(Res.string.external_import_installer_unknown) } companion object { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt index 308334ea5..344367e59 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt @@ -26,9 +26,17 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlin.math.roundToInt import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.components.InstalledAppIcon import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_skip +import zed.rainxch.githubstore.core.presentation.res.external_import_card_collapse_label +import zed.rainxch.githubstore.core.presentation.res.external_import_card_expand_label +import zed.rainxch.githubstore.core.presentation.res.external_import_card_installer_chip +import zed.rainxch.githubstore.core.presentation.res.external_import_card_preselect_known +import zed.rainxch.githubstore.core.presentation.res.external_import_card_preselect_unknown @Composable fun CandidateCard( @@ -46,6 +54,7 @@ fun CandidateCard( onSearchSubmit: () -> Unit, modifier: Modifier = Modifier, ) { + val expandLabel = stringResource(Res.string.external_import_card_expand_label) Surface( tonalElevation = 1.dp, shape = RoundedCornerShape(20.dp), @@ -56,7 +65,7 @@ fun CandidateCard( .let { base -> if (!expanded) { base.clickable( - onClickLabel = "Expand to see other matches", + onClickLabel = expandLabel, role = Role.Button, ) { onExpand() } } else { @@ -100,14 +109,12 @@ fun CandidateCard( verticalAlignment = Alignment.CenterVertically, ) { TextButton(onClick = onSkip) { - // TODO i18n: extract to strings.xml - Text("Skip") + Text(stringResource(Res.string.external_import_card_action_skip)) } IconButton(onClick = onCollapse) { Icon( imageVector = Icons.Default.KeyboardArrowUp, - // TODO i18n: extract to strings.xml - contentDescription = "Collapse card", + contentDescription = stringResource(Res.string.external_import_card_collapse_label), ) } } @@ -160,8 +167,7 @@ private fun InstallerChip(installerLabel: String) { shape = RoundedCornerShape(8.dp), ) { Text( - // TODO i18n: extract to strings.xml - text = "Installed via $installerLabel", + text = stringResource(Res.string.external_import_card_installer_chip, installerLabel), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), @@ -190,8 +196,7 @@ private fun PreselectedRow(suggestion: RepoSuggestionUi?) { ) { if (suggestion == null) { Text( - // TODO i18n: extract to strings.xml - text = "Tap to find a repo", + text = stringResource(Res.string.external_import_card_preselect_unknown), style = MaterialTheme.typography.bodyMedium, color = contentColor, modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), @@ -199,8 +204,12 @@ private fun PreselectedRow(suggestion: RepoSuggestionUi?) { } else { val percent = (suggestion.confidence * 100).roundToInt().coerceIn(0, 100) Text( - // TODO i18n: extract to strings.xml - text = "We think this is ${suggestion.ownerSlashRepo} · $percent%", + text = + stringResource( + Res.string.external_import_card_preselect_known, + suggestion.ownerSlashRepo, + percent, + ), style = MaterialTheme.typography.bodyMedium, color = contentColor, modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt index 3c05c023f..c9c19a521 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt @@ -18,6 +18,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_completion_action_view_all +import zed.rainxch.githubstore.core.presentation.res.external_import_completion_headline +import zed.rainxch.githubstore.core.presentation.res.external_import_completion_skipped_subline @Composable fun CompletionToast( @@ -46,8 +52,12 @@ fun CompletionToast( ) Text( - // TODO i18n: extract to strings.xml - text = "Now tracking $tracked apps.", + text = + pluralStringResource( + Res.plurals.external_import_completion_headline, + tracked, + tracked, + ), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, @@ -56,8 +66,7 @@ fun CompletionToast( if (skipped > 0) { Text( - // TODO i18n: extract to strings.xml - text = "Skipped $skipped — you can re-run a scan from Settings.", + text = stringResource(Res.string.external_import_completion_skipped_subline, skipped), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -65,8 +74,7 @@ fun CompletionToast( } Button(onClick = onExit) { - // TODO i18n: extract to strings.xml - Text("View all") + Text(stringResource(Res.string.external_import_completion_action_view_all)) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt index 7a31a8dd1..1637ab4f5 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt @@ -21,6 +21,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_empty_all_matched +import zed.rainxch.githubstore.core.presentation.res.external_import_empty_done +import zed.rainxch.githubstore.core.presentation.res.external_import_empty_grant_permission +import zed.rainxch.githubstore.core.presentation.res.external_import_empty_no_apps_body +import zed.rainxch.githubstore.core.presentation.res.external_import_empty_no_apps_title +import zed.rainxch.githubstore.core.presentation.res.external_import_empty_ok @Composable fun EmptyStateScreen( @@ -45,16 +53,14 @@ fun EmptyStateScreen( modifier = Modifier.size(64.dp), ) Text( - // TODO i18n: extract to strings.xml - text = "All matched.\nNothing to link.", + text = stringResource(Res.string.external_import_empty_all_matched), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) Button(onClick = onExit) { - // TODO i18n: extract to strings.xml - Text("Done") + Text(stringResource(Res.string.external_import_empty_done)) } } else { Icon( @@ -64,28 +70,24 @@ fun EmptyStateScreen( modifier = Modifier.size(72.dp), ) Text( - // TODO i18n: extract to strings.xml - text = "Couldn't find any GitHub apps on this device.", + text = stringResource(Res.string.external_import_empty_no_apps_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) Text( - // TODO i18n: extract to strings.xml - text = "This means either everything was installed via a different store, or we don't have visibility into what's installed.", + text = stringResource(Res.string.external_import_empty_no_apps_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedButton(onClick = onExit) { - // TODO i18n: extract to strings.xml - Text("OK") + Text(stringResource(Res.string.external_import_empty_ok)) } Button(onClick = onRequestPermission) { - // TODO i18n: extract to strings.xml - Text("Grant permission") + Text(stringResource(Res.string.external_import_empty_grant_permission)) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt index 1fcc6e839..e9c94e13f 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt @@ -18,7 +18,14 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.import.model.ImportPhase +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_progress_auto_importing +import zed.rainxch.githubstore.core.presentation.res.external_import_progress_scanning +import zed.rainxch.githubstore.core.presentation.res.external_import_progress_subtitle_count +import zed.rainxch.githubstore.core.presentation.res.external_import_progress_working @Composable fun ImportProgressScreen( @@ -28,12 +35,9 @@ fun ImportProgressScreen( ) { val headline = when (phase) { - // TODO i18n: extract to strings.xml - ImportPhase.Scanning -> "Scanning your apps…" - // TODO i18n: extract to strings.xml - ImportPhase.AutoImporting -> "Importing matches…" - // TODO i18n: extract to strings.xml - else -> "Working…" + ImportPhase.Scanning -> stringResource(Res.string.external_import_progress_scanning) + ImportPhase.AutoImporting -> stringResource(Res.string.external_import_progress_auto_importing) + else -> stringResource(Res.string.external_import_progress_working) } Box( @@ -60,8 +64,12 @@ fun ImportProgressScreen( ) Text( - // TODO i18n: extract to strings.xml - text = "Looked at $totalCandidates apps", + text = + pluralStringResource( + Res.plurals.external_import_progress_subtitle_count, + totalCandidates, + totalCandidates, + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt index f396d4542..4fe82b649 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt @@ -22,6 +22,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_proposal_banner_body +import zed.rainxch.githubstore.core.presentation.res.external_import_proposal_banner_dismiss +import zed.rainxch.githubstore.core.presentation.res.external_import_proposal_banner_headline +import zed.rainxch.githubstore.core.presentation.res.external_import_proposal_banner_review @Composable fun ImportProposalBanner( @@ -53,15 +60,18 @@ fun ImportProposalBanner( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - // TODO i18n: extract to strings.xml - text = "Found $pendingCount apps from GitHub", + text = + pluralStringResource( + Res.plurals.external_import_proposal_banner_headline, + pendingCount, + pendingCount, + ), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSecondaryContainer, ) Text( - // TODO i18n: extract to strings.xml - text = "Review them to track updates here.", + text = stringResource(Res.string.external_import_proposal_banner_body), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer, ) @@ -71,16 +81,14 @@ fun ImportProposalBanner( TextButton(onClick = onReview) { Text( - // TODO i18n: extract to strings.xml - text = "Review", + text = stringResource(Res.string.external_import_proposal_banner_review), ) } IconButton(onClick = onDismiss) { Icon( imageVector = Icons.Default.Close, - // TODO i18n: extract to strings.xml - contentDescription = "Dismiss", + contentDescription = stringResource(Res.string.external_import_proposal_banner_dismiss), ) } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt index 243353018..6bf0a56db 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt @@ -22,15 +22,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.import.ExternalImportAction import zed.rainxch.apps.presentation.import.util.rememberPackageVisibilityRequester import zed.rainxch.apps.presentation.import.util.rememberSdkInt - -private const val BODY_COPY = - "We can scan your installed apps and match them to GitHub releases — so updates and detection just work.\n\n" + - "To do that, we need to see which apps you have. Without permission we can only see about 5 apps; with it, we can see all of them.\n\n" + - "We never send the list of your apps anywhere without your permission. The match runs on your device. " + - "The optional backend lookup sends only the package name and app label of apps you ask us to match — never a full list of what's installed." +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_permission_body +import zed.rainxch.githubstore.core.presentation.res.external_import_permission_continue +import zed.rainxch.githubstore.core.presentation.res.external_import_permission_not_now +import zed.rainxch.githubstore.core.presentation.res.external_import_permission_title @Composable fun PermissionRationaleScreen( @@ -57,8 +57,7 @@ fun PermissionRationaleScreen( ) Text( - // TODO i18n: extract to strings.xml - text = "Find your GitHub apps", + text = stringResource(Res.string.external_import_permission_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, @@ -66,8 +65,7 @@ fun PermissionRationaleScreen( ) Text( - // TODO i18n: extract to strings.xml - text = BODY_COPY, + text = stringResource(Res.string.external_import_permission_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Start, @@ -77,8 +75,7 @@ fun PermissionRationaleScreen( OutlinedButton( onClick = { onAction(ExternalImportAction.OnPermissionDenied(sdkInt)) }, ) { - // TODO i18n: extract to strings.xml - Text("Not now") + Text(stringResource(Res.string.external_import_permission_not_now)) } Button(onClick = { scope.launch { @@ -94,8 +91,7 @@ fun PermissionRationaleScreen( } } }) { - // TODO i18n: extract to strings.xml - Text("Continue") + Text(stringResource(Res.string.external_import_permission_continue)) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt index 15ad10050..a4b69c0d6 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt @@ -26,8 +26,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlin.math.roundToInt +import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi import zed.rainxch.apps.presentation.import.model.SuggestionSource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_match_confidence_a11y +import zed.rainxch.githubstore.core.presentation.res.external_import_match_confidence_chip @Composable fun RepoCandidateRow( @@ -95,16 +99,18 @@ fun RepoCandidateRow( Spacer(Modifier.width(12.dp)) + val confidenceLabel = + stringResource(Res.string.external_import_match_confidence_a11y, percent) Surface( color = chipBg, shape = RoundedCornerShape(12.dp), modifier = Modifier.semantics { - contentDescription = "Match confidence: $percent percent" + contentDescription = confidenceLabel }, ) { Text( - text = "$percent%", + text = stringResource(Res.string.external_import_match_confidence_chip, percent), style = MaterialTheme.typography.labelMedium, color = chipFg, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt index 94f070216..82b7c22c4 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt @@ -22,7 +22,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_search_empty +import zed.rainxch.githubstore.core.presentation.res.external_import_search_icon_label +import zed.rainxch.githubstore.core.presentation.res.external_import_search_placeholder @Composable fun RepoSearchOverride( @@ -53,16 +58,14 @@ fun RepoSearchOverride( }, placeholder = { Text( - // TODO i18n: extract to strings.xml - text = "Not the right repo? Search…", + text = stringResource(Res.string.external_import_search_placeholder), ) }, trailingIcon = { IconButton(onClick = onSubmit) { Icon( imageVector = Icons.Default.Search, - // TODO i18n: extract to strings.xml - contentDescription = "Search GitHub", + contentDescription = stringResource(Res.string.external_import_search_icon_label), ) } }, @@ -93,8 +96,7 @@ fun RepoSearchOverride( } } else if (query.isNotBlank() && !isSearching) { Text( - // TODO i18n: extract to strings.xml - text = "No matches", + text = stringResource(Res.string.external_import_search_empty), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt index eb5aabb8e..7fbc89e46 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt @@ -37,9 +37,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi import zed.rainxch.apps.presentation.import.util.LocalReducedMotion +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_link +import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_skip +import zed.rainxch.githubstore.core.presentation.res.external_import_card_progress_chip @Composable fun WizardCardStack( @@ -117,15 +122,13 @@ fun WizardCardStack( onClick = onSkip, modifier = Modifier.weight(1f), ) { - // TODO i18n: extract to strings.xml - Text("Skip") + Text(stringResource(Res.string.external_import_card_action_skip)) } Button( onClick = onLink, modifier = Modifier.weight(1f), ) { - // TODO i18n: extract to strings.xml - Text("Link") + Text(stringResource(Res.string.external_import_card_action_link)) } } } @@ -139,8 +142,12 @@ private fun ProgressChip(currentIndex: Int, total: Int) { modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) { Text( - // TODO i18n: extract to strings.xml - text = "Card ${currentIndex + 1} of $total", + text = + stringResource( + Res.string.external_import_card_progress_chip, + currentIndex + 1, + total, + ), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index 8c74ae110..ef05cc14e 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -89,6 +89,11 @@ import zed.rainxch.githubstore.core.presentation.res.add_to_favourites import zed.rainxch.githubstore.core.presentation.res.cancel import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title +import zed.rainxch.githubstore.core.presentation.res.details_unlink_external_app_dialog_body +import zed.rainxch.githubstore.core.presentation.res.details_unlink_external_app_dialog_confirm +import zed.rainxch.githubstore.core.presentation.res.details_unlink_external_app_dialog_title +import zed.rainxch.githubstore.core.presentation.res.details_unlink_external_app_menu +import zed.rainxch.githubstore.core.presentation.res.details_unlink_external_app_more_options import zed.rainxch.githubstore.core.presentation.res.dismiss import zed.rainxch.githubstore.core.presentation.res.downgrade_requires_uninstall import zed.rainxch.githubstore.core.presentation.res.downgrade_warning_message @@ -302,15 +307,11 @@ fun DetailsRoot( viewModel.onAction(DetailsAction.OnDismissUnlinkConfirmation) }, title = { - // TODO i18n: extract to strings.xml - Text(text = "Unlink this app?") + Text(text = stringResource(Res.string.details_unlink_external_app_dialog_title)) }, text = { - // TODO i18n: extract to strings.xml Text( - text = - "We'll stop tracking $appName as installed from this repo. " + - "The app stays on your device — only the link is removed.", + text = stringResource(Res.string.details_unlink_external_app_dialog_body, appName), ) }, confirmButton = { @@ -319,9 +320,8 @@ fun DetailsRoot( viewModel.onAction(DetailsAction.OnConfirmUnlinkExternalApp) }, ) { - // TODO i18n: extract to strings.xml Text( - text = "Unlink", + text = stringResource(Res.string.details_unlink_external_app_dialog_confirm), color = MaterialTheme.colorScheme.error, ) } @@ -763,8 +763,7 @@ private fun DetailsTopbar( ) { Icon( imageVector = Icons.Default.MoreVert, - // TODO i18n: extract to strings.xml - contentDescription = "More options", + contentDescription = stringResource(Res.string.details_unlink_external_app_more_options), ) } DropdownMenu( @@ -773,8 +772,7 @@ private fun DetailsTopbar( ) { DropdownMenuItem( text = { - // TODO i18n: extract to strings.xml - Text(text = "Unlink from this repo") + Text(text = stringResource(Res.string.details_unlink_external_app_menu)) }, leadingIcon = { Icon( diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 1045dc3ba..9dc5f55a2 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -73,6 +73,8 @@ import zed.rainxch.details.presentation.model.SupportedLanguages import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.added_to_favourites +import zed.rainxch.githubstore.core.presentation.res.details_unlink_external_app_failure +import zed.rainxch.githubstore.core.presentation.res.details_unlink_external_app_success import zed.rainxch.githubstore.core.presentation.res.failed_to_open_app import zed.rainxch.githubstore.core.presentation.res.failed_to_share_link import zed.rainxch.githubstore.core.presentation.res.failed_to_uninstall @@ -178,17 +180,19 @@ class DetailsViewModel( installedAppsRepository.deleteInstalledApp(packageName) } runCatching { telemetryRepository.importUnlinkedFromDetails() } - // TODO i18n: extract to strings.xml _events.send( - DetailsEvent.OnMessage("Unlinked. We'll re-suggest a match next scan."), + DetailsEvent.OnMessage( + getString(Res.string.details_unlink_external_app_success), + ), ) } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Failed to unlink $packageName: ${e.message}") - // TODO i18n: extract to strings.xml _events.send( - DetailsEvent.OnMessage("Couldn't unlink — try again."), + DetailsEvent.OnMessage( + getString(Res.string.details_unlink_external_app_failure), + ), ) } } From c44b765bfcfac0b91ebb2d3a608dc9635498dcbf Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 19:47:28 +0500 Subject: [PATCH 21/46] E1: filter all FLAG_SYSTEM apps and OEM package prefixes from scan --- .../external/InstallerSourceClassifier.kt | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt index 9a3d3456e..4b7764e2a 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt @@ -17,15 +17,15 @@ class InstallerSourceClassifier( val flags = applicationInfo?.flags ?: 0 val isSystem = flags and ApplicationInfo.FLAG_SYSTEM != 0 - val isUpdatedSystem = flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 - val installer = installerPackageNameFor(packageName) - - if (installer == null && isSystem && !isUpdatedSystem) { - return InstallerKind.SYSTEM - } + // FLAG_SYSTEM apps that received an OTA update get FLAG_UPDATED_SYSTEM_APP added + // *without* losing FLAG_SYSTEM. Treat any FLAG_SYSTEM app as SYSTEM regardless of + // update status — Samsung / Pixel / OEM-bundled apps almost never come from GitHub. + if (isSystem) return InstallerKind.SYSTEM + if (OEM_PACKAGE_PREFIXES.any { packageName.startsWith(it) }) return InstallerKind.STORE_OEM_OTHER - return mapInstaller(installer, isSystem = isSystem && !isUpdatedSystem) + val installer = installerPackageNameFor(packageName) + return mapInstaller(installer, isSystem = false) } fun classifyByInstaller(installerPackageName: String?): InstallerKind = mapInstaller(installerPackageName, isSystem = false) @@ -105,5 +105,36 @@ class InstallerSourceClassifier( "com.android.packageinstaller", "com.google.android.packageinstaller", ) + + // Catch-all for OEM apps that lost FLAG_SYSTEM after a self-update or + // were preloaded as not-quite-system (Samsung does both). These are + // never GitHub-published; the wizard surfacing them is just noise. + private val OEM_PACKAGE_PREFIXES = + listOf( + "com.samsung.", + "com.sec.", + "com.lge.", + "com.huawei.", + "com.honor.", + "com.miui.", + "com.xiaomi.", + "com.mi.", + "com.oneplus.", + "com.oppo.", + "com.heytap.", + "com.coloros.", + "com.realme.", + "com.vivo.", + "com.iqoo.", + "com.motorola.", + "com.lenovo.", + "com.asus.", + "com.sony.", + "com.nokia.", + "com.htc.", + "com.amazon.", + "com.google.android.", + "com.android.", + ) } } From d9140ed1927a7914b1c3d0ae9952df0218f28a56 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 19:47:38 +0500 Subject: [PATCH 22/46] E1: prune stale pending rows on scan, expose decision snapshot for undo --- .../core/data/local/db/dao/ExternalLinkDao.kt | 3 ++ .../ExternalImportRepositoryImpl.kt | 52 +++++++++++++++++++ .../repository/ExternalImportRepository.kt | 5 ++ .../domain/system/ExternalDecisionSnapshot.kt | 12 +++++ 4 files changed, 72 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt index 40a613bad..740b699ca 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt @@ -35,4 +35,7 @@ interface ExternalLinkDao { @Query("DELETE FROM external_links WHERE state = 'SKIPPED' AND skipExpiresAt < :now") suspend fun pruneExpiredSkips(now: Long) + + @Query("DELETE FROM external_links WHERE state = 'PENDING_REVIEW' AND packageName NOT IN (:livePackages)") + suspend fun prunePendingReviewNotIn(livePackages: Set) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 2f5997b19..dc14ec3eb 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -27,6 +27,7 @@ import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner +import zed.rainxch.core.domain.system.ExternalDecisionSnapshot import zed.rainxch.core.domain.system.ExternalLinkState import zed.rainxch.core.domain.system.ImportSummary import zed.rainxch.core.domain.system.RepoMatchResult @@ -89,6 +90,14 @@ class ExternalImportRepositoryImpl( externalLinkDao.upsert(updated) } + // Prune PENDING_REVIEW rows whose package is no longer on the device or + // is no longer eligible (filtered by classifier, e.g. SYSTEM/PLAY/OEM). + // Without this, the banner count drifts past the actual reviewable set + // and the wizard ends up showing far fewer cards than the banner promised. + val livePackages = candidates.map { it.packageName }.toSet() + runCatching { externalLinkDao.prunePendingReviewNotIn(livePackages) } + .onFailure { Logger.d { "prune pending failed: ${it.message}" } } + val durationMs = nowMillis() - started runCatching { telemetry.importScanCompleted( @@ -342,6 +351,49 @@ class ExternalImportRepositoryImpl( candidateSnapshot.update { it - packageName } } + override suspend fun snapshotDecision(packageName: String): ExternalDecisionSnapshot? { + val row = externalLinkDao.get(packageName) ?: return null + return ExternalDecisionSnapshot( + packageName = row.packageName, + state = runCatching { ExternalLinkState.valueOf(row.state) }.getOrNull(), + repoOwner = row.repoOwner, + repoName = row.repoName, + matchSource = row.matchSource, + matchConfidence = row.matchConfidence, + skipExpiresAt = row.skipExpiresAt, + hadInstalledAppRow = false, + ) + } + + override suspend fun restoreDecision(snapshot: ExternalDecisionSnapshot) { + val now = nowMillis() + val state = snapshot.state ?: ExternalLinkState.PENDING_REVIEW + val existing = externalLinkDao.get(snapshot.packageName) + externalLinkDao.upsert( + (existing ?: ExternalLinkEntity( + packageName = snapshot.packageName, + state = state.name, + repoOwner = snapshot.repoOwner, + repoName = snapshot.repoName, + matchSource = snapshot.matchSource, + matchConfidence = snapshot.matchConfidence, + signingFingerprint = null, + installerKind = null, + firstSeenAt = now, + lastReviewedAt = now, + skipExpiresAt = snapshot.skipExpiresAt, + )).copy( + state = state.name, + repoOwner = snapshot.repoOwner, + repoName = snapshot.repoName, + matchSource = snapshot.matchSource, + matchConfidence = snapshot.matchConfidence, + skipExpiresAt = snapshot.skipExpiresAt, + lastReviewedAt = now, + ), + ) + } + override suspend fun rescanSinglePackage(packageName: String): RepoMatchResult? { val candidate = scanner.snapshotSingle(packageName) ?: return null candidateSnapshot.update { it + (packageName to candidate) } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt index c86f48e69..edd09b642 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt @@ -2,6 +2,7 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.ExternalDecisionSnapshot import zed.rainxch.core.domain.system.ImportSummary import zed.rainxch.core.domain.system.RepoMatchResult import zed.rainxch.core.domain.system.ScanResult @@ -35,6 +36,10 @@ interface ExternalImportRepository { suspend fun unlink(packageName: String) + suspend fun snapshotDecision(packageName: String): ExternalDecisionSnapshot? + + suspend fun restoreDecision(snapshot: ExternalDecisionSnapshot) + suspend fun rescanSinglePackage(packageName: String): RepoMatchResult? suspend fun searchRepos(query: String): Result> diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt new file mode 100644 index 000000000..185933c4f --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt @@ -0,0 +1,12 @@ +package zed.rainxch.core.domain.system + +data class ExternalDecisionSnapshot( + val packageName: String, + val state: ExternalLinkState?, + val repoOwner: String?, + val repoName: String?, + val matchSource: String?, + val matchConfidence: Double?, + val skipExpiresAt: Long?, + val hadInstalledAppRow: Boolean, +) From c79eaee56dc2dc2dd628a9b20b74e4ab08209dfa Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 20:00:36 +0500 Subject: [PATCH 23/46] E1: replace card stack with scrollable list, add undo snackbar, paste github url to link --- core/presentation/.DS_Store | Bin 0 -> 6148 bytes .../composeResources/values/strings.xml | 13 +- .../import/ExternalImportAction.kt | 20 +- .../import/ExternalImportEvent.kt | 2 + .../presentation/import/ExternalImportRoot.kt | 75 ++-- .../import/ExternalImportState.kt | 17 +- .../import/ExternalImportViewModel.kt | 339 +++++++++++++++--- .../import/components/CandidateCard.kt | 161 ++++++--- .../import/components/RepoSearchOverride.kt | 4 +- .../import/components/WizardCardStack.kt | 271 -------------- .../import/components/WizardList.kt | 94 +++++ 11 files changed, 578 insertions(+), 418 deletions(-) create mode 100644 core/presentation/.DS_Store delete mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt diff --git a/core/presentation/.DS_Store b/core/presentation/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6726bd65f0cda2a3dae3f96052612b5033c540ee GIT binary patch literal 6148 zcmeHKy-ve05Wb^9ip0{9(XY^f6{S@enEL`KjZ_ecQh_SgOswoY0t1f$eFS!1gzxSQ zX=o%iRKcBOfA;py2A0`R zmF)eH3R=?@Rn*&m@^Jmsx7JOb7gbY0o92&qC-0lr>35Iv2anZd$58Sjywiwo>4p|P z?oQv%JIi+7<)*w(o;7cbtD7$8u5~*fO*Nn7ba4ip0cXG&_`eLGW{adphTb~^&VV!U z#ekd-0ZlNA*c$55fli+QzyxX)jP;HQ459#L5nDrOAZ(#P3+4F4U<-#nM87OzYiQxb zni=cF%%6`JjyGHm>Q0;)dhZN41APWYHXTd-Kf|vy_{i^v_{)&{f-G!gM@WdOlgOEFMOF(z`39IMgnLB_CW5nDr9Mf4gD^oxKJ;+-?_3k-Y! D9=}K` literal 0 HcmV?d00001 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index bd2c2f153..a5e93110f 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -783,13 +783,11 @@ Skip Link - Card %1$d of %2$d Installed via %1$s We think this is %1$s · %2$d%% Tap to find a repo Expand to see other matches Collapse card - Not the right repo? Search… Search GitHub No matches Search failed @@ -828,6 +826,17 @@ Sideload GitHub Store Unknown source + Paste a github.com URL or search… + Linked %1$s. + Skipped %1$s. + Undo + Couldn\'t undo — try again. + More matches + Hide matches + + %1$d app to review + %1$d apps to review + Unlink from this repo diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt index f16413374..7d8e91c32 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt @@ -11,21 +11,27 @@ sealed interface ExternalImportAction { data class OnPermissionDenied(val sdkInt: Int?) : ExternalImportAction - data object OnSkipCurrentCard : ExternalImportAction + data class OnSkipCard(val packageName: String) : ExternalImportAction - data object OnSkipForever : ExternalImportAction + data class OnSkipForever(val packageName: String) : ExternalImportAction data object OnSkipRemaining : ExternalImportAction - data class OnPickSuggestion(val suggestion: RepoSuggestionUi) : ExternalImportAction + data class OnPickSuggestion( + val packageName: String, + val suggestion: RepoSuggestionUi, + ) : ExternalImportAction - data object OnExpandCurrentCard : ExternalImportAction + data class OnLinkCard(val packageName: String) : ExternalImportAction - data object OnCollapseCurrentCard : ExternalImportAction + data class OnToggleCardExpanded(val packageName: String) : ExternalImportAction - data class OnSearchOverrideChanged(val query: String) : ExternalImportAction + data class OnSearchOverrideChanged( + val packageName: String, + val query: String, + ) : ExternalImportAction - data object OnSearchOverrideSubmit : ExternalImportAction + data class OnSearchOverrideSubmit(val packageName: String) : ExternalImportAction data object OnUndoLast : ExternalImportAction diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt index 10e9dc373..6a4619395 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt @@ -8,4 +8,6 @@ sealed interface ExternalImportEvent { data object NavigateBack : ExternalImportEvent data object PlayConfetti : ExternalImportEvent + + data class ShowUndoSnackbar(val message: String) : ExternalImportEvent } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt index 1ff126cfe..ab8ef390b 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -31,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.apps.presentation.import.components.CompletionToast @@ -38,7 +40,7 @@ import zed.rainxch.apps.presentation.import.components.ConfettiOverlay import zed.rainxch.apps.presentation.import.components.EmptyStateScreen import zed.rainxch.apps.presentation.import.components.ImportProgressScreen import zed.rainxch.apps.presentation.import.components.PermissionRationaleScreen -import zed.rainxch.apps.presentation.import.components.WizardCardStack +import zed.rainxch.apps.presentation.import.components.WizardList import zed.rainxch.apps.presentation.import.model.ImportPhase import zed.rainxch.apps.presentation.import.util.LocalReducedMotion import zed.rainxch.apps.presentation.import.util.rememberSystemReducedMotion @@ -48,6 +50,7 @@ import zed.rainxch.githubstore.core.presentation.res.external_import_overflow_mo import zed.rainxch.githubstore.core.presentation.res.external_import_overflow_skip_remaining import zed.rainxch.githubstore.core.presentation.res.external_import_top_bar_back import zed.rainxch.githubstore.core.presentation.res.external_import_top_bar_title +import zed.rainxch.githubstore.core.presentation.res.external_import_undo_action @Composable fun ExternalImportRoot( @@ -68,6 +71,23 @@ fun ExternalImportRoot( scope.launch { snackbarHostState.showSnackbar(event.message) } } ExternalImportEvent.PlayConfetti -> confettiTrigger++ + is ExternalImportEvent.ShowUndoSnackbar -> { + // Dismiss any prior snackbar so undo always wins the slot — the + // VM tracks one pending undo, and showing a stale message would + // let the user mis-target it. + snackbarHostState.currentSnackbarData?.dismiss() + scope.launch { + val undoLabel = getString(Res.string.external_import_undo_action) + val result = snackbarHostState.showSnackbar( + message = event.message, + actionLabel = undoLabel, + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed) { + viewModel.onAction(ExternalImportAction.OnUndoLast) + } + } + } } } @@ -143,8 +163,7 @@ fun ExternalImportRoot( } ImportPhase.AwaitingReview -> { - val current = state.currentCard - if (current == null) { + if (state.cards.isEmpty()) { EmptyStateScreen( isPermissionDenied = state.isPermissionDenied, onRequestPermission = { @@ -153,43 +172,39 @@ fun ExternalImportRoot( onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, ) } else { - WizardCardStack( + WizardList( cards = state.cards, - currentIndex = state.currentCardIndex, - expanded = state.currentExpanded, - searchQuery = state.searchOverrideQuery, - searchResults = state.searchOverrideResults, + expandedPackages = state.expandedPackages, + activeSearchPackage = state.activeSearchPackage, + searchQuery = state.searchQuery, + searchResults = state.searchResults, isSearching = state.isSearching, searchError = state.searchError, - onExpand = { - viewModel.onAction(ExternalImportAction.OnExpandCurrentCard) - }, - onCollapse = { - viewModel.onAction(ExternalImportAction.OnCollapseCurrentCard) + onToggleExpanded = { pkg -> + viewModel.onAction( + ExternalImportAction.OnToggleCardExpanded(pkg), + ) }, - onPick = { suggestion -> - viewModel.onAction(ExternalImportAction.OnPickSuggestion(suggestion)) + onPick = { pkg, suggestion -> + viewModel.onAction( + ExternalImportAction.OnPickSuggestion(pkg, suggestion), + ) }, - onSkip = { - viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) + onSkip = { pkg -> + viewModel.onAction(ExternalImportAction.OnSkipCard(pkg)) }, - onLink = { - val preselect = current.preselectedSuggestion - if (preselect != null) { - viewModel.onAction( - ExternalImportAction.OnPickSuggestion(preselect), - ) - } else { - viewModel.onAction(ExternalImportAction.OnSkipCurrentCard) - } + onLink = { pkg -> + viewModel.onAction(ExternalImportAction.OnLinkCard(pkg)) }, - onSearchQueryChange = { query -> + onSearchQueryChange = { pkg, query -> viewModel.onAction( - ExternalImportAction.OnSearchOverrideChanged(query), + ExternalImportAction.OnSearchOverrideChanged(pkg, query), ) }, - onSearchSubmit = { - viewModel.onAction(ExternalImportAction.OnSearchOverrideSubmit) + onSearchSubmit = { pkg -> + viewModel.onAction( + ExternalImportAction.OnSearchOverrideSubmit(pkg), + ) }, ) } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt index 80d0308b8..76e4a1336 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt @@ -1,7 +1,9 @@ package zed.rainxch.apps.presentation.import import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.ImportPhase import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi @@ -13,10 +15,10 @@ data class ExternalImportState( val skipped: Int = 0, val manuallyLinked: Int = 0, val cards: ImmutableList = persistentListOf(), - val currentCardIndex: Int = 0, - val currentExpanded: Boolean = false, - val searchOverrideQuery: String = "", - val searchOverrideResults: ImmutableList = persistentListOf(), + val expandedPackages: ImmutableSet = persistentSetOf(), + val activeSearchPackage: String? = null, + val searchQuery: String = "", + val searchResults: ImmutableList = persistentListOf(), val isSearching: Boolean = false, val searchError: String? = null, val isPermissionDenied: Boolean = false, @@ -25,12 +27,9 @@ data class ExternalImportState( val showCompletionToast: Boolean = false, val errorMessage: String? = null, ) { - val currentCard: CandidateUi? - get() = cards.getOrNull(currentCardIndex) - val cardsRemaining: Int - get() = (cards.size - currentCardIndex).coerceAtLeast(0) + get() = cards.size val isWizardComplete: Boolean - get() = phase == ImportPhase.Done || (cards.isNotEmpty() && currentCardIndex >= cards.size) + get() = phase == ImportPhase.Done || (phase == ImportPhase.AwaitingReview && cards.isEmpty()) } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 3ba93a93c..0db9f4a05 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -3,7 +3,9 @@ package zed.rainxch.apps.presentation.import import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -24,8 +26,10 @@ import zed.rainxch.apps.presentation.import.model.SuggestionSource import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.repository.ExternalImportRepository +import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.system.ExternalAppCandidate +import zed.rainxch.core.domain.system.ExternalDecisionSnapshot import zed.rainxch.core.domain.system.InstallerKind import zed.rainxch.core.domain.system.RepoMatchResult import zed.rainxch.core.domain.system.RepoMatchSource @@ -41,10 +45,14 @@ import zed.rainxch.githubstore.core.presentation.res.external_import_installer_s import zed.rainxch.githubstore.core.presentation.res.external_import_installer_sideload import zed.rainxch.githubstore.core.presentation.res.external_import_installer_unknown import zed.rainxch.githubstore.core.presentation.res.external_import_search_error_default +import zed.rainxch.githubstore.core.presentation.res.external_import_undo_failed +import zed.rainxch.githubstore.core.presentation.res.external_import_undo_linked +import zed.rainxch.githubstore.core.presentation.res.external_import_undo_skipped class ExternalImportViewModel( private val externalImportRepository: ExternalImportRepository, private val appsRepository: AppsRepository, + private val installedAppsRepository: InstalledAppsRepository, private val telemetry: TelemetryRepository, private val logger: GitHubStoreLogger, ) : ViewModel() { @@ -52,6 +60,7 @@ class ExternalImportViewModel( private var hasStarted = false private var scanJob: Job? = null private var searchJob: Job? = null + private var pendingUndo: PendingUndo? = null private val _state = MutableStateFlow(ExternalImportState()) val state = @@ -93,32 +102,40 @@ class ExternalImportViewModel( startScanIfIdle(force = true) } - ExternalImportAction.OnSkipCurrentCard -> skipCurrent(neverAsk = false) + is ExternalImportAction.OnSkipCard -> skipPackage(action.packageName, neverAsk = false) - ExternalImportAction.OnSkipForever -> skipCurrent(neverAsk = true) + is ExternalImportAction.OnSkipForever -> skipPackage(action.packageName, neverAsk = true) ExternalImportAction.OnSkipRemaining -> skipRemaining() - is ExternalImportAction.OnPickSuggestion -> pickSuggestion(action.suggestion) + is ExternalImportAction.OnPickSuggestion -> + pickSuggestion(action.packageName, action.suggestion) - ExternalImportAction.OnExpandCurrentCard -> { - _state.update { it.copy(currentExpanded = true) } - } + is ExternalImportAction.OnLinkCard -> linkCardWithPreselected(action.packageName) - ExternalImportAction.OnCollapseCurrentCard -> { - _state.update { it.copy(currentExpanded = false) } - } + is ExternalImportAction.OnToggleCardExpanded -> toggleCardExpanded(action.packageName) is ExternalImportAction.OnSearchOverrideChanged -> { - // Explicit submit only: typing alone never fires a request, - // both because the existing UX expects an Enter/icon tap and - // to avoid hammering the rate-limited backend search. - _state.update { it.copy(searchOverrideQuery = action.query) } + // Explicit submit only — we never auto-fire on keystrokes. + _state.update { + if (it.activeSearchPackage != action.packageName) { + // Switched cards: drop stale results from the previous card. + it.copy( + activeSearchPackage = action.packageName, + searchQuery = action.query, + searchResults = persistentListOf(), + isSearching = false, + searchError = null, + ) + } else { + it.copy(searchQuery = action.query) + } + } } - ExternalImportAction.OnSearchOverrideSubmit -> submitSearchOverride() + is ExternalImportAction.OnSearchOverrideSubmit -> submitSearchOverride(action.packageName) - ExternalImportAction.OnUndoLast -> Unit + ExternalImportAction.OnUndoLast -> undoLast() ExternalImportAction.OnExit -> { viewModelScope.launch { @@ -132,6 +149,28 @@ class ExternalImportViewModel( } } + private fun toggleCardExpanded(packageName: String) { + _state.update { current -> + val nextSet = + if (packageName in current.expandedPackages) { + current.expandedPackages.toPersistentSet().remove(packageName) + } else { + current.expandedPackages.toPersistentSet().add(packageName) + } + // Clear cross-card search results when collapsing the active card so + // they don't bleed into the next card the user expands. + val keepSearch = current.activeSearchPackage == packageName && packageName in nextSet + current.copy( + expandedPackages = nextSet, + activeSearchPackage = if (keepSearch) current.activeSearchPackage else null, + searchQuery = if (keepSearch) current.searchQuery else "", + searchResults = if (keepSearch) current.searchResults else persistentListOf(), + isSearching = if (keepSearch) current.isSearching else false, + searchError = if (keepSearch) current.searchError else null, + ) + } + } + private fun startScanIfIdle(force: Boolean = false) { if (!force && _state.value.phase != ImportPhase.Idle) return if (scanJob?.isActive == true) return @@ -175,7 +214,6 @@ class ExternalImportViewModel( it.copy( phase = ImportPhase.Done, cards = persistentListOf(), - currentCardIndex = 0, autoImported = autoLinked.size, showCompletionToast = true, ) @@ -186,8 +224,6 @@ class ExternalImportViewModel( it.copy( phase = ImportPhase.AwaitingReview, cards = cards, - currentCardIndex = 0, - currentExpanded = false, autoImported = autoLinked.size, ) } @@ -230,20 +266,54 @@ class ExternalImportViewModel( ) } - private fun submitSearchOverride() { - val query = _state.value.searchOverrideQuery.trim() + private fun submitSearchOverride(packageName: String) { + val current = _state.value + // Search submit applies to whichever card was last typed in. If the + // active package and the submitted package mismatch, we still honour + // the submit using the active query — this only happens if the user + // taps the icon in a different card before the keystrokes registered, + // which the UI prevents via per-card query binding. + if (current.activeSearchPackage != packageName) return + + val query = current.searchQuery.trim() if (query.isEmpty()) { searchJob?.cancel() _state.update { it.copy( isSearching = false, searchError = null, - searchOverrideResults = persistentListOf(), + searchResults = persistentListOf(), ) } return } + // Fast-path: a github.com/owner/repo URL bypasses the search API and + // surfaces a single MANUAL suggestion that the user can tap to link. + // This unblocks users with rate-limited search and matches Obtainium's + // "paste a URL" mental model. + parseGithubRepoUrl(query)?.let { (owner, repo) -> + searchJob?.cancel() + _state.update { + it.copy( + isSearching = false, + searchError = null, + searchResults = persistentListOf( + RepoSuggestionUi( + owner = owner, + repo = repo, + confidence = 1.0, + source = SuggestionSource.MANUAL, + stars = null, + description = null, + ), + ), + ) + } + viewModelScope.launch { runCatching { telemetry.importSearchOverrideUsed() } } + return + } + searchJob?.cancel() _state.update { it.copy(isSearching = true, searchError = null) } viewModelScope.launch { runCatching { telemetry.importSearchOverrideUsed() } } @@ -260,10 +330,14 @@ class ExternalImportViewModel( runCatching { telemetry.importSearchOverrideNoResults() } } _state.update { - it.copy( + // Stale-completion guard: if the user collapsed or switched cards + // while the request was in flight, drop the response on the floor + // so old results never bleed into a newly-active card. + if (it.activeSearchPackage != packageName) it + else it.copy( isSearching = false, searchError = null, - searchOverrideResults = + searchResults = suggestions.map { s -> s.toUi() }.toImmutableList(), ) } @@ -273,10 +347,11 @@ class ExternalImportViewModel( logger.error("Search override failed for '$query': ${e.message}") val fallback = getString(Res.string.external_import_search_error_default) _state.update { - it.copy( + if (it.activeSearchPackage != packageName) it + else it.copy( isSearching = false, searchError = e.message ?: fallback, - searchOverrideResults = persistentListOf(), + searchResults = persistentListOf(), ) } }, @@ -284,15 +359,20 @@ class ExternalImportViewModel( } } - private fun skipCurrent(neverAsk: Boolean) { - val current = _state.value.currentCard ?: return + private fun skipPackage(packageName: String, neverAsk: Boolean) { + val card = _state.value.cards.firstOrNull { it.packageName == packageName } ?: return viewModelScope.launch { + val snapshot = runCatching { + externalImportRepository.snapshotDecision(packageName) + }.getOrNull() + val hadInstalledRow = installedAppsRepository.getAppByPackage(packageName) != null + try { - externalImportRepository.skipPackage(current.packageName, neverAsk = neverAsk) + externalImportRepository.skipPackage(packageName, neverAsk = neverAsk) } catch (e: CancellationException) { throw e } catch (e: Exception) { - logger.error("Skip failed for ${current.packageName}: ${e.message}") + logger.error("Skip failed for $packageName: ${e.message}") } runCatching { telemetry.importSkipped( @@ -300,19 +380,31 @@ class ExternalImportViewModel( persisted = if (neverAsk) "forever" else "7day", ) } - advanceAfter { it.copy(skipped = it.skipped + 1) } + removeCardFromState(packageName) { it.copy(skipped = it.skipped + 1) } + + pendingUndo = PendingUndo( + card = card, + snapshot = snapshot, + hadInstalledAppRowBefore = hadInstalledRow, + kind = PendingUndo.Kind.Skip, + ) + _events.send( + ExternalImportEvent.ShowUndoSnackbar( + getString(Res.string.external_import_undo_skipped, card.appLabel), + ), + ) } } - private fun pickSuggestion(suggestion: RepoSuggestionUi) { - val current = _state.value.currentCard ?: return - val preselected = current.preselectedSuggestion + private fun pickSuggestion(packageName: String, suggestion: RepoSuggestionUi) { + val card = _state.value.cards.firstOrNull { it.packageName == packageName } ?: return + val preselected = card.preselectedSuggestion val source = if (suggestion == preselected) "preselected" else "alternative" - val candidate = candidatesByPackage[current.packageName] + val candidate = candidatesByPackage[packageName] viewModelScope.launch { if (candidate == null) { - logger.error("Cannot materialize ${current.packageName}: candidate missing from snapshot") + logger.error("Cannot materialize $packageName: candidate missing from snapshot") _events.send( ExternalImportEvent.ShowError( getString(Res.string.external_import_error_link_failed), @@ -321,6 +413,11 @@ class ExternalImportViewModel( return@launch } + val snapshot = runCatching { + externalImportRepository.snapshotDecision(packageName) + }.getOrNull() + val hadInstalledRow = installedAppsRepository.getAppByPackage(packageName) != null + val materialized = materializeAndMark(candidate, suggestion.owner, suggestion.repo, source) if (!materialized) { _events.send( @@ -333,7 +430,94 @@ class ExternalImportViewModel( runCatching { telemetry.importManuallyLinked(countBucket = "1-2", source = source) } - advanceAfter { it.copy(manuallyLinked = it.manuallyLinked + 1) } + removeCardFromState(packageName) { it.copy(manuallyLinked = it.manuallyLinked + 1) } + + pendingUndo = PendingUndo( + card = card, + snapshot = snapshot, + hadInstalledAppRowBefore = hadInstalledRow, + kind = PendingUndo.Kind.Link, + ) + _events.send( + ExternalImportEvent.ShowUndoSnackbar( + getString(Res.string.external_import_undo_linked, card.appLabel), + ), + ) + } + } + + private fun linkCardWithPreselected(packageName: String) { + val card = _state.value.cards.firstOrNull { it.packageName == packageName } ?: return + val preselect = card.preselectedSuggestion + if (preselect != null) { + pickSuggestion(packageName, preselect) + } else { + // No preselection means there's nothing to confidently link to. The + // list-mode UI hides the link CTA in this case, but defensively + // surface the expand affordance instead of silently dropping. + toggleCardExpanded(packageName) + } + } + + private fun undoLast() { + val undo = pendingUndo ?: return + pendingUndo = null + + viewModelScope.launch { + try { + if (undo.kind == PendingUndo.Kind.Link && !undo.hadInstalledAppRowBefore) { + // The link materialized a new installed_apps row; remove it + // before restoring the link table state so getAppByPackage + // observers see the rollback in one shot. + runCatching { + installedAppsRepository.deleteInstalledApp(undo.packageName) + } + } + + if (undo.snapshot != null) { + externalImportRepository.restoreDecision(undo.snapshot) + } else { + // No prior row existed (first-ever scan + decision). Drop + // the new link entirely so the candidate becomes pending + // again on the next scan. + runCatching { externalImportRepository.unlink(undo.packageName) } + } + + // Re-insert the original card at the top so the user can retry + // immediately. We use the card we cached on the snackbar token — + // re-running resolveMatches would issue a network call and risks + // returning different suggestions than the user just saw. + _state.update { current -> + if (current.cards.any { it.packageName == undo.packageName }) { + current + } else { + val tally = when (undo.kind) { + PendingUndo.Kind.Link -> + current.copy( + manuallyLinked = (current.manuallyLinked - 1).coerceAtLeast(0), + ) + PendingUndo.Kind.Skip -> + current.copy( + skipped = (current.skipped - 1).coerceAtLeast(0), + ) + } + tally.copy( + cards = (listOf(undo.card) + current.cards).toImmutableList(), + phase = ImportPhase.AwaitingReview, + showCompletionToast = false, + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Undo failed for ${undo.packageName}: ${e.message}") + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_undo_failed), + ), + ) + } } } @@ -369,7 +553,7 @@ class ExternalImportViewModel( private fun skipRemaining() { val current = _state.value if (current.phase != ImportPhase.AwaitingReview) return - val remaining = current.cards.drop(current.currentCardIndex) + val remaining = current.cards if (remaining.isEmpty()) return viewModelScope.launch { @@ -390,10 +574,20 @@ class ExternalImportViewModel( ) } + // Skip-remaining is intentionally not undoable — bulk skip clears + // the wizard and triggers the completion screen, and a single + // snackbar isn't a sensible affordance for "undo seven things". + pendingUndo = null + _state.update { it.copy( - currentCardIndex = it.cards.size, - currentExpanded = false, + cards = persistentListOf(), + expandedPackages = persistentSetOf(), + activeSearchPackage = null, + searchQuery = "", + searchResults = persistentListOf(), + isSearching = false, + searchError = null, phase = ImportPhase.Done, skipped = it.skipped + remaining.size, showCompletionToast = true, @@ -485,24 +679,34 @@ class ExternalImportViewModel( signingFingerprint = signingFingerprint, ) - private suspend fun advanceAfter(transform: (ExternalImportState) -> ExternalImportState) { - val nextIndex = _state.value.currentCardIndex + 1 - val total = _state.value.cards.size - val done = nextIndex >= total - + private suspend fun removeCardFromState( + packageName: String, + tally: (ExternalImportState) -> ExternalImportState, + ) { + // _state.update may invoke the lambda multiple times under contention; + // never assign captured vars from inside it. Read the post-update + // state to decide whether to fire the completion event. _state.update { current -> - val tallied = transform(current).copy(currentExpanded = false) - if (done) { + val newCards = current.cards.filterNot { it.packageName == packageName }.toImmutableList() + val tallied = tally(current).copy( + cards = newCards, + expandedPackages = current.expandedPackages.toPersistentSet().remove(packageName), + activeSearchPackage = if (current.activeSearchPackage == packageName) null else current.activeSearchPackage, + searchQuery = if (current.activeSearchPackage == packageName) "" else current.searchQuery, + searchResults = if (current.activeSearchPackage == packageName) persistentListOf() else current.searchResults, + isSearching = if (current.activeSearchPackage == packageName) false else current.isSearching, + searchError = if (current.activeSearchPackage == packageName) null else current.searchError, + ) + if (newCards.isEmpty()) { tallied.copy( phase = ImportPhase.Done, showCompletionToast = true, ) } else { - tallied.copy(currentCardIndex = nextIndex) + tallied } } - - if (done) { + if (_state.value.cards.isEmpty()) { _events.send(ExternalImportEvent.PlayConfetti) } } @@ -534,9 +738,46 @@ class ExternalImportViewModel( else -> getString(Res.string.external_import_installer_unknown) } + private data class PendingUndo( + val card: CandidateUi, + val snapshot: ExternalDecisionSnapshot?, + val hadInstalledAppRowBefore: Boolean, + val kind: Kind, + ) { + val packageName: String get() = card.packageName + val appLabel: String get() = card.appLabel + + enum class Kind { Skip, Link } + } + companion object { private const val AUTO_LINK_THRESHOLD = 0.85 private const val PRESELECT_MIN = 0.5 private const val PRESELECT_MAX = 0.85 } } + +private fun parseGithubRepoUrl(input: String): Pair? { + val trimmed = input.trim().removeSuffix("/") + if (trimmed.isEmpty()) return null + // Accept both bare host references and full https URLs. Anything else + // (search keywords, partial slugs without owner) falls through to the + // backend search path. + val withoutScheme = trimmed + .removePrefix("https://") + .removePrefix("http://") + .removePrefix("www.") + if (!withoutScheme.startsWith("github.com/", ignoreCase = true)) return null + val path = withoutScheme.substring("github.com/".length) + val parts = path.split('/').filter { it.isNotEmpty() } + if (parts.size < 2) return null + val owner = parts[0] + val repo = parts[1].substringBefore('?').substringBefore('#') + if (!isValidGithubSegment(owner) || !isValidGithubSegment(repo)) return null + return owner to repo +} + +private fun isValidGithubSegment(s: String): Boolean = + s.isNotEmpty() && + s.length <= 100 && + s.all { it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt index 344367e59..853e7af28 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt @@ -1,5 +1,10 @@ package zed.rainxch.apps.presentation.import.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -9,10 +14,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Button import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -30,7 +37,11 @@ import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.components.InstalledAppIcon import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi +import zed.rainxch.apps.presentation.import.util.LocalReducedMotion import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_less +import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_link +import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_more import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_skip import zed.rainxch.githubstore.core.presentation.res.external_import_card_collapse_label import zed.rainxch.githubstore.core.presentation.res.external_import_card_expand_label @@ -46,15 +57,18 @@ fun CandidateCard( searchResults: ImmutableList, isSearching: Boolean, searchError: String?, - onExpand: () -> Unit, - onCollapse: () -> Unit, + onToggleExpanded: () -> Unit, onPick: (RepoSuggestionUi) -> Unit, onSkip: () -> Unit, + onLink: () -> Unit, onSearchQueryChange: (String) -> Unit, onSearchSubmit: () -> Unit, modifier: Modifier = Modifier, ) { val expandLabel = stringResource(Res.string.external_import_card_expand_label) + val collapseLabel = stringResource(Res.string.external_import_card_collapse_label) + val reducedMotion = LocalReducedMotion.current + Surface( tonalElevation = 1.dp, shape = RoundedCornerShape(20.dp), @@ -62,60 +76,78 @@ fun CandidateCard( modifier = modifier .fillMaxWidth() - .let { base -> - if (!expanded) { - base.clickable( - onClickLabel = expandLabel, - role = Role.Button, - ) { onExpand() } - } else { - base - } - }, + .clickable( + onClickLabel = if (expanded) collapseLabel else expandLabel, + role = Role.Button, + ) { onToggleExpanded() }, ) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { CandidateHeader(candidate = candidate) + PreselectedRow(suggestion = candidate.preselectedSuggestion) + + // Collapsed footer: primary Link CTA (or hint) + expand affordance. + // The whole card is clickable to expand, but a dedicated control + // gives the disclosure a clear screen-reader role and a tap target + // that doesn't fight the underlying CTA buttons in expanded mode. if (!expanded) { - PreselectedRow(suggestion = candidate.preselectedSuggestion) - } else { - if (candidate.suggestions.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - candidate.suggestions.take(3).forEach { suggestion -> - RepoCandidateRow( - suggestion = suggestion, - onPick = onPick, - ) + CollapsedActions( + canLink = candidate.preselectedSuggestion != null, + onLink = onLink, + onExpand = onToggleExpanded, + ) + } + + AnimatedVisibility( + visible = expanded, + enter = + if (reducedMotion) fadeIn() else fadeIn() + expandVertically(), + exit = + if (reducedMotion) fadeOut() else fadeOut() + shrinkVertically(), + ) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + if (candidate.suggestions.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + candidate.suggestions.take(3).forEach { suggestion -> + RepoCandidateRow( + suggestion = suggestion, + onPick = onPick, + ) + } } } - } - RepoSearchOverride( - query = searchQuery, - results = searchResults, - isSearching = isSearching, - searchError = searchError, - onQueryChange = onSearchQueryChange, - onSubmit = onSearchSubmit, - onPick = onPick, - ) + RepoSearchOverride( + query = searchQuery, + results = searchResults, + isSearching = isSearching, + searchError = searchError, + onQueryChange = onSearchQueryChange, + onSubmit = onSearchSubmit, + onPick = onPick, + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onSkip) { - Text(stringResource(Res.string.external_import_card_action_skip)) - } - IconButton(onClick = onCollapse) { - Icon( - imageVector = Icons.Default.KeyboardArrowUp, - contentDescription = stringResource(Res.string.external_import_card_collapse_label), - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onSkip, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.external_import_card_action_skip)) + } + TextButton(onClick = onToggleExpanded) { + Text(stringResource(Res.string.external_import_card_action_less)) + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = null, + ) + } } } } @@ -123,6 +155,38 @@ fun CandidateCard( } } +@Composable +private fun CollapsedActions( + canLink: Boolean, + onLink: () -> Unit, + onExpand: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (canLink) { + Button( + onClick = onLink, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.external_import_card_action_link)) + } + } + TextButton( + onClick = onExpand, + modifier = if (canLink) Modifier else Modifier.weight(1f), + ) { + Text(stringResource(Res.string.external_import_card_action_more)) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + ) + } + } +} + @Composable private fun CandidateHeader(candidate: CandidateUi) { Row( @@ -135,8 +199,8 @@ private fun CandidateHeader(candidate: CandidateUi) { appName = candidate.appLabel, modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(16.dp)), + .size(48.dp) + .clip(RoundedCornerShape(14.dp)), ) Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -217,3 +281,4 @@ private fun PreselectedRow(suggestion: RepoSuggestionUi?) { } } } + diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt index 82b7c22c4..a9dbd06ef 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt @@ -27,7 +27,7 @@ import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.external_import_search_empty import zed.rainxch.githubstore.core.presentation.res.external_import_search_icon_label -import zed.rainxch.githubstore.core.presentation.res.external_import_search_placeholder +import zed.rainxch.githubstore.core.presentation.res.external_import_search_placeholder_url @Composable fun RepoSearchOverride( @@ -58,7 +58,7 @@ fun RepoSearchOverride( }, placeholder = { Text( - text = stringResource(Res.string.external_import_search_placeholder), + text = stringResource(Res.string.external_import_search_placeholder_url), ) }, trailingIcon = { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt deleted file mode 100644 index 7fbc89e46..000000000 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardCardStack.kt +++ /dev/null @@ -1,271 +0,0 @@ -package zed.rainxch.apps.presentation.import.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.hideFromAccessibility -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.apps.presentation.import.model.CandidateUi -import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi -import zed.rainxch.apps.presentation.import.util.LocalReducedMotion -import zed.rainxch.githubstore.core.presentation.res.Res -import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_link -import zed.rainxch.githubstore.core.presentation.res.external_import_card_action_skip -import zed.rainxch.githubstore.core.presentation.res.external_import_card_progress_chip - -@Composable -fun WizardCardStack( - cards: ImmutableList, - currentIndex: Int, - expanded: Boolean, - searchQuery: String, - searchResults: ImmutableList, - isSearching: Boolean, - searchError: String?, - onExpand: () -> Unit, - onCollapse: () -> Unit, - onPick: (RepoSuggestionUi) -> Unit, - onSkip: () -> Unit, - onLink: () -> Unit, - onSearchQueryChange: (String) -> Unit, - onSearchSubmit: () -> Unit, - modifier: Modifier = Modifier, -) { - val current = cards.getOrNull(currentIndex) ?: return - val next = cards.getOrNull(currentIndex + 1) - val afterNext = cards.getOrNull(currentIndex + 2) - - Column( - modifier = modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - ProgressChip( - currentIndex = currentIndex, - total = cards.size, - ) - - Box( - modifier = Modifier.fillMaxWidth().weight(1f), - contentAlignment = Alignment.TopCenter, - ) { - BoxWithConstraints( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter, - ) { - val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() } - - if (afterNext != null) { - GhostedCard(card = afterNext, depth = 2) - } - if (next != null) { - GhostedCard(card = next, depth = 1) - } - - FrontCard( - candidate = current, - expanded = expanded, - searchQuery = searchQuery, - searchResults = searchResults, - isSearching = isSearching, - searchError = searchError, - parentWidthPx = maxWidthPx, - cardKey = currentIndex, - onExpand = onExpand, - onCollapse = onCollapse, - onPick = onPick, - onSkip = onSkip, - onLink = onLink, - onSearchQueryChange = onSearchQueryChange, - onSearchSubmit = onSearchSubmit, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = onSkip, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(Res.string.external_import_card_action_skip)) - } - Button( - onClick = onLink, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(Res.string.external_import_card_action_link)) - } - } - } -} - -@Composable -private fun ProgressChip(currentIndex: Int, total: Int) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) { - Text( - text = - stringResource( - Res.string.external_import_card_progress_chip, - currentIndex + 1, - total, - ), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) - } -} - -@Composable -private fun GhostedCard(card: CandidateUi, depth: Int) { - val (offsetDp, scale, ghostColor) = - when (depth) { - 1 -> Triple(8.dp, 0.96f, MaterialTheme.colorScheme.surfaceContainerHigh) - else -> Triple(16.dp, 0.92f, MaterialTheme.colorScheme.surfaceContainer) - } - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(top = offsetDp) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .semantics(mergeDescendants = true) { hideFromAccessibility() }, - shape = RoundedCornerShape(20.dp), - color = ghostColor, - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - text = card.appLabel, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - ) - Spacer(Modifier.height(40.dp)) - } - } -} - -@Composable -private fun FrontCard( - candidate: CandidateUi, - expanded: Boolean, - searchQuery: String, - searchResults: ImmutableList, - isSearching: Boolean, - searchError: String?, - parentWidthPx: Float, - cardKey: Int, - onExpand: () -> Unit, - onCollapse: () -> Unit, - onPick: (RepoSuggestionUi) -> Unit, - onSkip: () -> Unit, - onLink: () -> Unit, - onSearchQueryChange: (String) -> Unit, - onSearchSubmit: () -> Unit, -) { - val offsetX = remember(cardKey) { Animatable(0f) } - val scope = rememberCoroutineScope() - val swipeThreshold = parentWidthPx * 0.25f - val reducedMotion = LocalReducedMotion.current - val rotationFactor = if (reducedMotion) 0f else 1f - - LaunchedEffect(cardKey) { - offsetX.snapTo(0f) - } - - val draggable = - rememberDraggableState { delta -> - scope.launch { offsetX.snapTo(offsetX.value + delta) } - } - - Surface( - modifier = - Modifier - .fillMaxWidth() - .graphicsLayer { - translationX = offsetX.value - rotationZ = (offsetX.value / 60f * rotationFactor).coerceIn(-12f, 12f) - } - .draggable( - state = draggable, - orientation = Orientation.Horizontal, - enabled = !expanded, - onDragStopped = { - when { - offsetX.value > swipeThreshold -> { - offsetX.animateTo(parentWidthPx, tween(200)) - onLink() - } - offsetX.value < -swipeThreshold -> { - offsetX.animateTo(-parentWidthPx, tween(200)) - onSkip() - } - else -> offsetX.animateTo(0f, tween(180)) - } - }, - ), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(20.dp), - tonalElevation = 0.dp, - ) { - CandidateCard( - candidate = candidate, - expanded = expanded, - searchQuery = searchQuery, - searchResults = searchResults, - isSearching = isSearching, - searchError = searchError, - onExpand = onExpand, - onCollapse = onCollapse, - onPick = onPick, - onSkip = onSkip, - onSearchQueryChange = onSearchQueryChange, - onSearchSubmit = onSearchSubmit, - ) - } - -} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt new file mode 100644 index 000000000..15b063070 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt @@ -0,0 +1,94 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import org.jetbrains.compose.resources.pluralStringResource +import zed.rainxch.apps.presentation.import.model.CandidateUi +import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_list_remaining + +@Composable +fun WizardList( + cards: ImmutableList, + expandedPackages: ImmutableSet, + activeSearchPackage: String?, + searchQuery: String, + searchResults: ImmutableList, + isSearching: Boolean, + searchError: String?, + onToggleExpanded: (packageName: String) -> Unit, + onPick: (packageName: String, RepoSuggestionUi) -> Unit, + onSkip: (packageName: String) -> Unit, + onLink: (packageName: String) -> Unit, + onSearchQueryChange: (packageName: String, query: String) -> Unit, + onSearchSubmit: (packageName: String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "progress-header") { + ProgressChip(remaining = cards.size) + } + + items( + items = cards, + key = { it.packageName }, + ) { card -> + val expanded = card.packageName in expandedPackages + val isActive = activeSearchPackage == card.packageName + CandidateCard( + candidate = card, + expanded = expanded, + searchQuery = if (isActive) searchQuery else "", + searchResults = if (isActive) searchResults else persistentListOf(), + isSearching = isActive && isSearching, + searchError = if (isActive) searchError else null, + onToggleExpanded = { onToggleExpanded(card.packageName) }, + onPick = { suggestion -> onPick(card.packageName, suggestion) }, + onSkip = { onSkip(card.packageName) }, + onLink = { onLink(card.packageName) }, + onSearchQueryChange = { q -> onSearchQueryChange(card.packageName, q) }, + onSearchSubmit = { onSearchSubmit(card.packageName) }, + ) + } + } +} + +@Composable +private fun ProgressChip(remaining: Int) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Text( + text = pluralStringResource(Res.plurals.external_import_list_remaining, remaining, remaining), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} From da568d735398757793d87c50105349bd43326b0a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 20:34:41 +0500 Subject: [PATCH 24/46] E1: auto-import summary screen and add-manually entry from wizard --- .../app/navigation/AppNavigation.kt | 24 ++- .../composeResources/values/strings.xml | 14 ++ .../import/ExternalImportAction.kt | 6 + .../import/ExternalImportEvent.kt | 2 + .../presentation/import/ExternalImportRoot.kt | 26 +++ .../import/ExternalImportState.kt | 2 + .../import/ExternalImportViewModel.kt | 109 ++++++++++++- .../components/AutoImportSummaryScreen.kt | 150 ++++++++++++++++++ .../import/components/EmptyStateScreen.kt | 9 ++ .../import/components/WizardList.kt | 33 ++++ .../presentation/import/model/ImportPhase.kt | 1 + 11 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index e444b2d94..be3fec905 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,6 +45,10 @@ import zed.rainxch.search.presentation.SearchRoot import zed.rainxch.starred.presentation.StarredReposRoot import zed.rainxch.tweaks.presentation.TweaksRoot +// Cross-screen "return result" key: set by the external-import wizard's +// "Add manually" path before navigateUp(), read once by the Apps screen. +private const val EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY = "external_import_open_link_sheet" + @Composable fun AppNavigation( navController: NavHostController, @@ -297,7 +302,18 @@ fun AppNavigation( TweaksRoot() } - composable { + composable { backStackEntry -> + // Pick up the "open link sheet" flag set by ExternalImportRoot's + // "Add manually" path. We consume the flag once on entry so a + // later config change or back-stack rewind doesn't reopen the sheet. + LaunchedEffect(backStackEntry) { + val handle = backStackEntry.savedStateHandle + val openLinkSheet = handle.get(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY) + if (openLinkSheet == true) { + handle.remove(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY) + appsViewModel.onAction(zed.rainxch.apps.presentation.AppsAction.OnAddByLinkClick) + } + } AppsRoot( onNavigateBack = { navController.navigateUp() @@ -331,6 +347,12 @@ fun AppNavigation( ), ) }, + onAddManually = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY, true) + navController.navigateUp() + }, ) } } diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index a5e93110f..310dfb282 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -837,6 +837,20 @@ %1$d app to review %1$d apps to review + + Linked %1$d app automatically + Linked %1$d apps automatically + + We recognized them with high confidence. You can undo individual matches from each app\'s details, or undo all now. + + %1$d more needs your input. + %1$d more need your input. + + Continue + Undo all + +%1$d more + Don\'t see your app? Add manually + Add an app from another store Unlink from this repo diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt index 7d8e91c32..b08d5ead7 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt @@ -38,4 +38,10 @@ sealed interface ExternalImportAction { data object OnExit : ExternalImportAction data object OnDismissCompletionToast : ExternalImportAction + + data object OnAutoSummaryContinue : ExternalImportAction + + data object OnAutoSummaryUndoAll : ExternalImportAction + + data object OnAddManually : ExternalImportAction } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt index 6a4619395..ddcf44463 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt @@ -10,4 +10,6 @@ sealed interface ExternalImportEvent { data object PlayConfetti : ExternalImportEvent data class ShowUndoSnackbar(val message: String) : ExternalImportEvent + + data object NavigateBackAndOpenManualLink : ExternalImportEvent } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt index ab8ef390b..a44a9650f 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.apps.presentation.import.components.AutoImportSummaryScreen import zed.rainxch.apps.presentation.import.components.CompletionToast import zed.rainxch.apps.presentation.import.components.ConfettiOverlay import zed.rainxch.apps.presentation.import.components.EmptyStateScreen @@ -56,6 +57,7 @@ import zed.rainxch.githubstore.core.presentation.res.external_import_undo_action fun ExternalImportRoot( onNavigateBack: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, + onAddManually: () -> Unit, viewModel: ExternalImportViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -67,6 +69,7 @@ fun ExternalImportRoot( when (event) { ExternalImportEvent.NavigateBack -> onNavigateBack() is ExternalImportEvent.NavigateToDetails -> onNavigateToDetails(event.repoId) + ExternalImportEvent.NavigateBackAndOpenManualLink -> onAddManually() is ExternalImportEvent.ShowError -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } @@ -162,6 +165,20 @@ fun ExternalImportRoot( ) } + ImportPhase.AutoImportSummary -> { + AutoImportSummaryScreen( + autoLinkedCount = state.autoImported, + autoLinkedLabels = state.autoLinkedLabels, + cardsRemaining = state.cards.size, + onContinue = { + viewModel.onAction(ExternalImportAction.OnAutoSummaryContinue) + }, + onUndoAll = { + viewModel.onAction(ExternalImportAction.OnAutoSummaryUndoAll) + }, + ) + } + ImportPhase.AwaitingReview -> { if (state.cards.isEmpty()) { EmptyStateScreen( @@ -170,6 +187,9 @@ fun ExternalImportRoot( viewModel.onAction(ExternalImportAction.OnRequestPermission) }, onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + onAddManually = { + viewModel.onAction(ExternalImportAction.OnAddManually) + }, ) } else { WizardList( @@ -206,6 +226,9 @@ fun ExternalImportRoot( ExternalImportAction.OnSearchOverrideSubmit(pkg), ) }, + onAddManually = { + viewModel.onAction(ExternalImportAction.OnAddManually) + }, ) } } @@ -219,6 +242,9 @@ fun ExternalImportRoot( viewModel.onAction(ExternalImportAction.OnRequestPermission) }, onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + onAddManually = { + viewModel.onAction(ExternalImportAction.OnAddManually) + }, ) } else { CompletionToast( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt index 76e4a1336..65553561b 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt @@ -15,6 +15,8 @@ data class ExternalImportState( val skipped: Int = 0, val manuallyLinked: Int = 0, val cards: ImmutableList = persistentListOf(), + val autoLinkedPackages: ImmutableList = persistentListOf(), + val autoLinkedLabels: ImmutableList = persistentListOf(), val expandedPackages: ImmutableSet = persistentSetOf(), val activeSearchPackage: String? = null, val searchQuery: String = "", diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 0db9f4a05..6774fe862 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -57,6 +58,10 @@ class ExternalImportViewModel( private val logger: GitHubStoreLogger, ) : ViewModel() { private var candidatesByPackage: Map = emptyMap() + // Cached so OnAutoSummaryUndoAll can re-build cards for previously + // auto-linked packages without round-tripping resolveMatches() (which + // would issue another network call and could return different matches). + private var lastResolvedMatches: List = emptyList() private var hasStarted = false private var scanJob: Job? = null private var searchJob: Job? = null @@ -146,6 +151,16 @@ class ExternalImportViewModel( ExternalImportAction.OnDismissCompletionToast -> { _state.update { it.copy(showCompletionToast = false) } } + + ExternalImportAction.OnAutoSummaryContinue -> autoSummaryContinue() + + ExternalImportAction.OnAutoSummaryUndoAll -> autoSummaryUndoAll() + + ExternalImportAction.OnAddManually -> { + viewModelScope.launch { + _events.send(ExternalImportEvent.NavigateBackAndOpenManualLink) + } + } } } @@ -194,6 +209,7 @@ class ExternalImportViewModel( } val matches = externalImportRepository.resolveMatches(candidates) + lastResolvedMatches = matches val autoLinked = autoMaterialize(matches) val autoLinkedPackages = autoLinked.toSet() @@ -209,12 +225,27 @@ class ExternalImportViewModel( buildCard(candidate, match) }.toImmutableList() - if (cards.isEmpty()) { + if (autoLinked.isNotEmpty()) { + // Stop on the summary screen so the user sees what auto-linked + // and can undo before we cascade into the review wizard. + val autoLinkedLabels = autoLinked.mapNotNull { pkg -> + candidatesByPackage[pkg]?.appLabel + } + _state.update { + it.copy( + phase = ImportPhase.AutoImportSummary, + cards = cards, + autoImported = autoLinked.size, + autoLinkedPackages = autoLinked.toPersistentList(), + autoLinkedLabels = autoLinkedLabels.toPersistentList(), + ) + } + } else if (cards.isEmpty()) { _state.update { it.copy( phase = ImportPhase.Done, cards = persistentListOf(), - autoImported = autoLinked.size, + autoImported = 0, showCompletionToast = true, ) } @@ -224,7 +255,7 @@ class ExternalImportViewModel( it.copy( phase = ImportPhase.AwaitingReview, cards = cards, - autoImported = autoLinked.size, + autoImported = 0, ) } } @@ -521,6 +552,78 @@ class ExternalImportViewModel( } } + private fun autoSummaryContinue() { + val current = _state.value + if (current.phase != ImportPhase.AutoImportSummary) return + if (current.cards.isNotEmpty()) { + _state.update { it.copy(phase = ImportPhase.AwaitingReview) } + } else { + _state.update { + it.copy( + phase = ImportPhase.Done, + showCompletionToast = true, + ) + } + viewModelScope.launch { _events.send(ExternalImportEvent.PlayConfetti) } + } + } + + private fun autoSummaryUndoAll() { + val current = _state.value + if (current.phase != ImportPhase.AutoImportSummary) return + val packages = current.autoLinkedPackages.toList() + if (packages.isEmpty()) { + _state.update { it.copy(phase = ImportPhase.AwaitingReview) } + return + } + + viewModelScope.launch { + // Roll each auto-linked package back to PENDING_REVIEW. Snapshot → + // restoreDecision matches the per-row undo path, so the audit trail + // and DAO state mirror what the user would see after a fresh scan + // with no auto-link applied. + packages.forEach { pkg -> + val snapshot = runCatching { + externalImportRepository.snapshotDecision(pkg) + }.getOrNull() + runCatching { installedAppsRepository.deleteInstalledApp(pkg) } + if (snapshot != null) { + runCatching { externalImportRepository.restoreDecision(snapshot) } + } else { + runCatching { externalImportRepository.unlink(pkg) } + } + } + + // Bulk-undo invalidates the single-row undo token: the user cleared + // the auto-link wave wholesale, so a stale "Undo" snackbar from a + // pre-summary action would now point at a row we just restored. + pendingUndo = null + + val matchesByPkg = lastResolvedMatches.associateBy { it.packageName } + val restoredCards = packages.mapNotNull { pkg -> + val candidate = candidatesByPackage[pkg] ?: return@mapNotNull null + buildCard(candidate, matchesByPkg[pkg]) + } + + _state.update { state -> + val merged = (restoredCards + state.cards) + .distinctBy { it.packageName } + .toImmutableList() + state.copy( + phase = if (merged.isEmpty()) ImportPhase.Done else ImportPhase.AwaitingReview, + cards = merged, + autoImported = 0, + autoLinkedPackages = persistentListOf(), + autoLinkedLabels = persistentListOf(), + showCompletionToast = merged.isEmpty(), + ) + } + if (_state.value.cards.isEmpty()) { + _events.send(ExternalImportEvent.PlayConfetti) + } + } + } + private fun emitPermissionOutcome(granted: Boolean, sdkInt: Int?) { viewModelScope.launch { runCatching { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt new file mode 100644 index 000000000..bbf632bb9 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt @@ -0,0 +1,150 @@ +package zed.rainxch.apps.presentation.import.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.TextAlign +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.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_body +import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_continue +import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_headline +import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_more_count +import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_review_hint +import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_undo_all + +@Composable +fun AutoImportSummaryScreen( + autoLinkedCount: Int, + autoLinkedLabels: ImmutableList, + cardsRemaining: Int, + onContinue: () -> Unit, + onUndoAll: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.widthIn(max = 480.dp), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(72.dp), + ) + + Text( + text = pluralStringResource( + Res.plurals.external_import_auto_summary_headline, + autoLinkedCount, + autoLinkedCount, + ), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(Res.string.external_import_auto_summary_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + if (cardsRemaining > 0) { + Text( + text = pluralStringResource( + Res.plurals.external_import_auto_summary_review_hint, + cardsRemaining, + cardsRemaining, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + if (autoLinkedLabels.isNotEmpty()) { + AutoLinkedChipRow(autoLinkedLabels = autoLinkedLabels) + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = onUndoAll) { + Text(stringResource(Res.string.external_import_auto_summary_undo_all)) + } + Button(onClick = onContinue) { + Text(stringResource(Res.string.external_import_auto_summary_continue)) + } + } + } + } +} + +@Composable +private fun AutoLinkedChipRow(autoLinkedLabels: ImmutableList) { + val visible = autoLinkedLabels.take(MAX_VISIBLE_CHIPS) + val overflow = autoLinkedLabels.size - visible.size + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + visible.forEach { label -> + ChipSurface(text = label) + } + if (overflow > 0) { + ChipSurface( + text = stringResource( + Res.string.external_import_auto_summary_more_count, + overflow, + ), + ) + } + } +} + +@Composable +private fun ChipSurface(text: String) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} + +private const val MAX_VISIBLE_CHIPS = 5 diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt index 1637ab4f5..1697fa674 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_empty_add_manually import zed.rainxch.githubstore.core.presentation.res.external_import_empty_all_matched import zed.rainxch.githubstore.core.presentation.res.external_import_empty_done import zed.rainxch.githubstore.core.presentation.res.external_import_empty_grant_permission @@ -35,6 +37,7 @@ fun EmptyStateScreen( isPermissionDenied: Boolean, onRequestPermission: () -> Unit, onExit: () -> Unit, + onAddManually: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -62,6 +65,9 @@ fun EmptyStateScreen( Button(onClick = onExit) { Text(stringResource(Res.string.external_import_empty_done)) } + TextButton(onClick = onAddManually) { + Text(stringResource(Res.string.external_import_empty_add_manually)) + } } else { Icon( imageVector = Icons.Outlined.Search, @@ -90,6 +96,9 @@ fun EmptyStateScreen( Text(stringResource(Res.string.external_import_empty_grant_permission)) } } + TextButton(onClick = onAddManually) { + Text(stringResource(Res.string.external_import_empty_add_manually)) + } } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt index 15b063070..3ceae3d12 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt @@ -3,13 +3,19 @@ package zed.rainxch.apps.presentation.import.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.LiveRegionMode @@ -21,9 +27,11 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource import zed.rainxch.apps.presentation.import.model.CandidateUi import zed.rainxch.apps.presentation.import.model.RepoSuggestionUi import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.external_import_list_add_manually import zed.rainxch.githubstore.core.presentation.res.external_import_list_remaining @Composable @@ -41,6 +49,7 @@ fun WizardList( onLink: (packageName: String) -> Unit, onSearchQueryChange: (packageName: String, query: String) -> Unit, onSearchSubmit: (packageName: String) -> Unit, + onAddManually: () -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -73,6 +82,30 @@ fun WizardList( onSearchSubmit = { onSearchSubmit(card.packageName) }, ) } + + item(key = "add-manually-footer") { + AddManuallyFooter(onClick = onAddManually) + } + } +} + +@Composable +private fun AddManuallyFooter(onClick: () -> Unit) { + TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(Res.string.external_import_list_add_manually), + style = MaterialTheme.typography.bodyMedium, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp) + .size(16.dp), + ) } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt index cc2ef5b2c..6a7cdf677 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt @@ -5,6 +5,7 @@ enum class ImportPhase { RequestingPermission, Scanning, AutoImporting, + AutoImportSummary, AwaitingReview, Done, } From 9829d367ab57e49f64b351813ff08d2507ffcc91 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 20:52:11 +0500 Subject: [PATCH 25/46] E1: reconcile pending state on cold start, persist banner dismiss until count grows --- .../ExternalImportRepositoryImpl.kt | 20 ++++++++---- .../data/repository/TweaksRepositoryImpl.kt | 13 ++++++++ .../domain/repository/TweaksRepository.kt | 4 +++ .../apps/presentation/AppsViewModel.kt | 32 +++++++++++++++---- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index dc14ec3eb..ea8a5b09e 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -62,14 +62,22 @@ class ExternalImportRepositoryImpl( override fun pendingCandidateCountFlow(): Flow = externalLinkDao.observePendingReviewCount() override suspend fun scheduleInitialScanIfNeeded() { - val alreadyScanned = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] != null - if (alreadyScanned) return + // Reconcile on every cold start. The scan itself is local-only + // (PackageManager + DAO writes), idempotent against MATCHED/SKIPPED + // rows, and prunes stale PENDING_REVIEW rows that no longer pass the + // current scanner filter. Without this, banner counts persist past + // app updates that tightened the filter — leading to "banner says 50 + // apps but the wizard finds zero". + val firstLaunch = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] == null runCatching { - runCatching { telemetry.importScanStarted(trigger = "first_launch") } - .onFailure { Logger.d { "telemetry importScanStarted failed: ${it.message}" } } + if (firstLaunch) { + runCatching { telemetry.importScanStarted(trigger = "first_launch") } + .onFailure { Logger.d { "telemetry importScanStarted failed: ${it.message}" } } + } runFullScan() - }.onSuccess { markInitialScanComplete() } - .onFailure { Logger.w(it) { "Initial external scan failed; will retry on next launch." } } + }.onSuccess { + if (firstLaunch) markInitialScanComplete() + }.onFailure { Logger.w(it) { "External scan failed; will retry on next launch." } } } override suspend fun runFullScan(): ScanResult { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index b70503ede..17f468720 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow @@ -252,6 +253,17 @@ class TweaksRepositoryImpl( } } + override fun getExternalImportBannerDismissedAtCount(): Flow = + preferences.data.map { prefs -> + prefs[EXTERNAL_IMPORT_BANNER_DISMISSED_AT_KEY] ?: 0 + } + + override suspend fun setExternalImportBannerDismissedAtCount(count: Int) { + preferences.edit { prefs -> + prefs[EXTERNAL_IMPORT_BANNER_DISMISSED_AT_KEY] = count + } + } + companion object { private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L @@ -275,5 +287,6 @@ class TweaksRepositoryImpl( private val APP_LANGUAGE_KEY = stringPreferencesKey("app_language") private val EXTERNAL_IMPORT_ENABLED_KEY = booleanPreferencesKey("external_import_enabled") private val EXTERNAL_MATCH_SEARCH_ENABLED_KEY = booleanPreferencesKey("external_match_search_enabled") + private val EXTERNAL_IMPORT_BANNER_DISMISSED_AT_KEY = intPreferencesKey("external_import_banner_dismissed_at") } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index 7e3df7b22..cfad78dc5 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -94,4 +94,8 @@ interface TweaksRepository { fun getExternalMatchSearchEnabled(): Flow suspend fun setExternalMatchSearchEnabled(enabled: Boolean) + + fun getExternalImportBannerDismissedAtCount(): Flow + + suspend fun setExternalImportBannerDismissedAtCount(count: Int) } 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 6e1f10a71..a2ad86514 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 @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -104,14 +105,22 @@ class AppsViewModel( private fun observePendingExternalImports() { viewModelScope.launch { - externalImportRepository.pendingCandidateCountFlow().collect { count -> - _state.update { - it.copy( - pendingExternalImportCount = count, - showImportProposalBanner = count >= BANNER_THRESHOLD && !it.isExternalImportInFlight, - ) + externalImportRepository.pendingCandidateCountFlow() + .combine(tweaksRepository.getExternalImportBannerDismissedAtCount()) { count, dismissedAt -> + count to dismissedAt + } + .collect { (count, dismissedAt) -> + // Banner re-shows only when the live count exceeds the count + // captured at dismiss time. Without this, every DAO emission + // (delta scan, package event) overrides the user's dismiss. + val shouldShow = count >= BANNER_THRESHOLD && count > dismissedAt + _state.update { + it.copy( + pendingExternalImportCount = count, + showImportProposalBanner = shouldShow && !it.isExternalImportInFlight, + ) + } } - } } } @@ -462,12 +471,21 @@ class AppsViewModel( AppsAction.OnImportProposalReview -> { _state.update { it.copy(showImportProposalBanner = false) } viewModelScope.launch { + // Reviewing implies the user is acting on the current set, + // so wipe the dismiss watermark — banner can re-show next + // time as count comes back >0 (or stays >0 if any cards + // remained un-decided). + runCatching { tweaksRepository.setExternalImportBannerDismissedAtCount(0) } _events.send(AppsEvent.NavigateToExternalImport) } } AppsAction.OnImportProposalDismiss -> { + val current = _state.value.pendingExternalImportCount _state.update { it.copy(showImportProposalBanner = false) } + viewModelScope.launch { + runCatching { tweaksRepository.setExternalImportBannerDismissedAtCount(current) } + } } } } From be9c57436b5c37975f7f840a47f7259ce9b7bf60 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 21:00:25 +0500 Subject: [PATCH 26/46] E1: only surface candidates with positive evidence (trusted installer, manifest hint, or fingerprint hit) --- .../ExternalImportRepositoryImpl.kt | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index ea8a5b09e..2f7be1dec 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -83,7 +83,12 @@ class ExternalImportRepositoryImpl( override suspend fun runFullScan(): ScanResult { val started = nowMillis() val granted = scanner.isPermissionGranted() - val candidates = scanner.snapshot() + val rawCandidates = scanner.snapshot() + // Surface only candidates with positive evidence that the app is + // GitHub-published. Without this we'd show every app whose installer + // we didn't recognise (UNKNOWN bucket) — a flood of Samsung utilities, + // random sideloads, and OEM apps the user knows aren't open source. + val candidates = rawCandidates.filter { hasPositiveEvidence(it) } candidateSnapshot.update { candidates.associateBy { it.packageName } } val now = nowMillis() @@ -98,10 +103,9 @@ class ExternalImportRepositoryImpl( externalLinkDao.upsert(updated) } - // Prune PENDING_REVIEW rows whose package is no longer on the device or - // is no longer eligible (filtered by classifier, e.g. SYSTEM/PLAY/OEM). - // Without this, the banner count drifts past the actual reviewable set - // and the wizard ends up showing far fewer cards than the banner promised. + // Prune PENDING_REVIEW rows whose package is gone or no longer passes + // the evidence filter. Without this, the banner count drifts past the + // actual reviewable set after we tightened the policy. val livePackages = candidates.map { it.packageName }.toSet() runCatching { externalLinkDao.prunePendingReviewNotIn(livePackages) } .onFailure { Logger.d { "prune pending failed: ${it.message}" } } @@ -117,7 +121,7 @@ class ExternalImportRepositoryImpl( return ScanResult( totalCandidates = candidates.size, newCandidates = newCandidates, - autoLinked = 0, // wired with backend match resolver in Week 2 + autoLinked = 0, pendingReview = pendingReview, durationMillis = durationMs, permissionGranted = granted, @@ -502,6 +506,13 @@ class ExternalImportRepositoryImpl( } } + private suspend fun hasPositiveEvidence(candidate: ExternalAppCandidate): Boolean { + if (candidate.installerKind in TRUSTED_GITHUB_INSTALLERS) return true + if (candidate.manifestHint != null) return true + val fp = candidate.signingFingerprint ?: return false + return runCatching { signingFingerprintDao.lookup(fp) != null }.getOrDefault(false) + } + private fun mergeCandidate( existing: ExternalLinkEntity?, candidate: ExternalAppCandidate, @@ -614,5 +625,13 @@ class ExternalImportRepositoryImpl( private const val MAX_SEARCH_QUERY_LEN = 100 private const val MAX_SEED_PAGES = 50 private const val SEED_SOURCE_BACKEND = "backend_seed" + + // Stores whose entire catalog is sourced from GitHub releases — apps installed + // through them are surfaced even without a manifest hint or fingerprint match. + private val TRUSTED_GITHUB_INSTALLERS = + setOf( + zed.rainxch.core.domain.system.InstallerKind.STORE_OBTAINIUM, + zed.rainxch.core.domain.system.InstallerKind.STORE_FDROID, + ) } } From 2a5acb9a85e015c46baa30f528a38ea1a099de95 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 26 Apr 2026 21:15:10 +0500 Subject: [PATCH 27/46] E1: instrument scan, banner, package events, and worker with E1Debug logs --- .../rainxch/githubstore/app/GithubStoreApp.kt | 14 +++- .../data/services/PackageEventReceiver.kt | 10 ++- .../core/data/services/UpdateCheckWorker.kt | 11 ++- .../ExternalImportRepositoryImpl.kt | 75 +++++++++++++------ .../apps/presentation/AppsViewModel.kt | 6 +- .../import/ExternalImportViewModel.kt | 15 +++- 6 files changed, 95 insertions(+), 36 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 64fb91c5f..551090a03 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -45,20 +45,26 @@ class GithubStoreApp : Application() { } private fun scheduleInitialExternalScan() { + Logger.withTag("E1Debug").i { "GithubStoreApp scheduleInitialExternalScan invoked" } appScope.launch { runCatching { get().scheduleInitialScanIfNeeded() - }.onFailure { Logger.w(it) { "Initial external scan scheduling failed" } } + }.onFailure { + Logger.withTag("E1Debug").w(it) { "GithubStoreApp scheduleInitialExternalScan FAILED" } + Logger.w(it) { "Initial external scan scheduling failed" } + } } } - // Best-effort: signingFingerprintDao.lastSyncTimestamp() acts as the - // since cursor inside the repo, so repeat calls are cheap. private fun scheduleSigningSeedSync() { + Logger.withTag("E1Debug").i { "GithubStoreApp scheduleSigningSeedSync invoked" } appScope.launch { runCatching { get().syncSigningFingerprintSeed() - }.onFailure { Logger.w(it) { "Signing seed sync failed" } } + }.onFailure { + Logger.withTag("E1Debug").w(it) { "GithubStoreApp scheduleSigningSeedSync FAILED" } + Logger.w(it) { "Signing seed sync failed" } + } } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index 789f60d71..2ad4adae0 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -88,6 +88,7 @@ class PackageEventReceiver() : val packageName = intent?.data?.schemeSpecificPart ?: return Logger.d { "PackageEventReceiver: ${intent.action} for $packageName" } + Logger.withTag("E1Debug").i { "PackageEventReceiver action=${intent.action} pkg=$packageName" } try { when (intent.action) { @@ -169,10 +170,15 @@ class PackageEventReceiver() : // path above. getBackstopScope().launch { runCatching { - if (shouldRescan(packageName)) { + val rescan = shouldRescan(packageName) + Logger.withTag("E1Debug").i { "PackageEventReceiver shouldRescan pkg=$packageName -> $rescan" } + if (rescan) { getExternalImport().runDeltaScan(setOf(packageName)) } - }.onFailure { Logger.w(it) { "Delta scan failed for $packageName" } } + }.onFailure { + Logger.withTag("E1Debug").w(it) { "PackageEventReceiver delta scan failed pkg=$packageName" } + Logger.w(it) { "Delta scan failed for $packageName" } + } } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index 557c4f216..b752448b0 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -50,6 +50,7 @@ class UpdateCheckWorker( override suspend fun doWork(): Result = try { Logger.i { "UpdateCheckWorker: Starting periodic update check" } + Logger.withTag("E1Debug").i { "UpdateCheckWorker doWork ENTER attempt=$runAttemptCount" } // Run as foreground service to prevent OS from killing the worker setForeground(createForegroundInfo("Checking for updates...")) @@ -100,9 +101,13 @@ class UpdateCheckWorker( // broadcast we missed (process killed, OEM app-standby, etc.). // Cap at 50 so a 200-package device doesn't drag the worker. private suspend fun runPeriodicExternalDeltaScan() { + Logger.withTag("E1Debug").i { "UpdateCheckWorker runPeriodicExternalDeltaScan ENTER" } try { val installed = packageMonitor.getAllInstalledPackageNames() - if (installed.isEmpty()) return + if (installed.isEmpty()) { + Logger.withTag("E1Debug").i { "UpdateCheckWorker delta installed=0 EXIT" } + return + } val trackedFlow = installedAppsRepository.getAllInstalledApps().first() val tracked = trackedFlow.map { it.packageName }.toSet() @@ -113,6 +118,9 @@ class UpdateCheckWorker( ).toSet() val delta = (installed - tracked - permanent).take(MAX_DELTA_PACKAGES).toSet() + Logger.withTag("E1Debug").i { + "UpdateCheckWorker delta installed=${installed.size} tracked=${tracked.size} permanent=${permanent.size} -> delta=${delta.size}" + } if (delta.isEmpty()) { Logger.d { "UpdateCheckWorker: external delta scan empty" } return @@ -122,6 +130,7 @@ class UpdateCheckWorker( externalImportRepository.runDeltaScan(delta) } catch (e: Exception) { Logger.w { "UpdateCheckWorker: external delta scan failed: ${e.message}" } + Logger.withTag("E1Debug").w(e) { "UpdateCheckWorker delta FAILED" } } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 2f7be1dec..1308c9f9f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -50,6 +50,18 @@ class ExternalImportRepositoryImpl( // next scan rather than persisted to keep the schema small. private val candidateSnapshot = MutableStateFlow>(emptyMap()) + private val debug = Logger.withTag(E1_DEBUG_TAG) + + private suspend fun upsertWithLog(row: ExternalLinkEntity, callsite: String) { + externalLinkDao.upsert(row) + debug.i { "upsert from=$callsite pkg=${row.packageName} state=${row.state} repo=${row.repoOwner}/${row.repoName}" } + } + + private suspend fun deleteWithLog(packageName: String, callsite: String) { + externalLinkDao.deleteByPackageName(packageName) + debug.i { "delete from=$callsite pkg=$packageName" } + } + override fun pendingCandidatesFlow(): Flow> = combine( candidateSnapshot, @@ -62,13 +74,8 @@ class ExternalImportRepositoryImpl( override fun pendingCandidateCountFlow(): Flow = externalLinkDao.observePendingReviewCount() override suspend fun scheduleInitialScanIfNeeded() { - // Reconcile on every cold start. The scan itself is local-only - // (PackageManager + DAO writes), idempotent against MATCHED/SKIPPED - // rows, and prunes stale PENDING_REVIEW rows that no longer pass the - // current scanner filter. Without this, banner counts persist past - // app updates that tightened the filter — leading to "banner says 50 - // apps but the wizard finds zero". val firstLaunch = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] == null + debug.i { "scheduleInitialScanIfNeeded enter firstLaunch=$firstLaunch" } runCatching { if (firstLaunch) { runCatching { telemetry.importScanStarted(trigger = "first_launch") } @@ -77,37 +84,41 @@ class ExternalImportRepositoryImpl( runFullScan() }.onSuccess { if (firstLaunch) markInitialScanComplete() - }.onFailure { Logger.w(it) { "External scan failed; will retry on next launch." } } + debug.i { "scheduleInitialScanIfNeeded done firstLaunch=$firstLaunch" } + }.onFailure { + debug.w(it) { "scheduleInitialScanIfNeeded FAILED" } + Logger.w(it) { "External scan failed; will retry on next launch." } + } } override suspend fun runFullScan(): ScanResult { val started = nowMillis() + debug.i { "runFullScan ENTER" } val granted = scanner.isPermissionGranted() val rawCandidates = scanner.snapshot() - // Surface only candidates with positive evidence that the app is - // GitHub-published. Without this we'd show every app whose installer - // we didn't recognise (UNKNOWN bucket) — a flood of Samsung utilities, - // random sideloads, and OEM apps the user knows aren't open source. + debug.i { "runFullScan scanner.snapshot returned ${rawCandidates.size} raw candidates (granted=$granted)" } val candidates = rawCandidates.filter { hasPositiveEvidence(it) } + debug.i { "runFullScan after positive-evidence filter: ${candidates.size} kept (dropped ${rawCandidates.size - candidates.size})" } candidateSnapshot.update { candidates.associateBy { it.packageName } } val now = nowMillis() var newCandidates = 0 var pendingReview = 0 + var preservedDecisions = 0 candidates.forEach { candidate -> val existing = externalLinkDao.get(candidate.packageName) val updated = mergeCandidate(existing, candidate, now) if (existing == null) newCandidates++ if (updated.state == ExternalLinkState.PENDING_REVIEW.name) pendingReview++ - externalLinkDao.upsert(updated) + if (existing != null && updated.state != ExternalLinkState.PENDING_REVIEW.name) preservedDecisions++ + upsertWithLog(updated, callsite = "runFullScan") } + debug.i { "runFullScan upserted: new=$newCandidates pendingReview=$pendingReview preservedDecisions=$preservedDecisions" } - // Prune PENDING_REVIEW rows whose package is gone or no longer passes - // the evidence filter. Without this, the banner count drifts past the - // actual reviewable set after we tightened the policy. val livePackages = candidates.map { it.packageName }.toSet() runCatching { externalLinkDao.prunePendingReviewNotIn(livePackages) } + .onSuccess { debug.i { "runFullScan prunePendingReviewNotIn livePackages=${livePackages.size}" } } .onFailure { Logger.d { "prune pending failed: ${it.message}" } } val durationMs = nowMillis() - started @@ -129,6 +140,7 @@ class ExternalImportRepositoryImpl( } override suspend fun runDeltaScan(changedPackageNames: Set): ScanResult { + debug.i { "runDeltaScan ENTER changed=${changedPackageNames.size} packages=$changedPackageNames" } val started = nowMillis() val granted = scanner.isPermissionGranted() val now = nowMillis() @@ -137,9 +149,10 @@ class ExternalImportRepositoryImpl( val deltaCandidates = mutableListOf() changedPackageNames.forEach { pkg -> - val candidate = scanner.snapshotSingle(pkg) + val rawCandidate = scanner.snapshotSingle(pkg) + val candidate = rawCandidate?.takeIf { hasPositiveEvidence(it) } if (candidate == null) { - externalLinkDao.deleteByPackageName(pkg) + deleteWithLog(pkg, callsite = "runDeltaScan(no-evidence-or-uninstalled)") return@forEach } deltaCandidates += candidate @@ -147,8 +160,9 @@ class ExternalImportRepositoryImpl( val updated = mergeCandidate(existing, candidate, now) if (existing == null) newCandidates++ if (updated.state == ExternalLinkState.PENDING_REVIEW.name) pendingReview++ - externalLinkDao.upsert(updated) + upsertWithLog(updated, callsite = "runDeltaScan") } + debug.i { "runDeltaScan upserted: new=$newCandidates pendingReview=$pendingReview deltaSize=${deltaCandidates.size}" } if (deltaCandidates.isNotEmpty()) { candidateSnapshot.update { current -> @@ -248,6 +262,7 @@ class ExternalImportRepositoryImpl( } override suspend fun importAutoMatched(matches: List): ImportSummary { + debug.i { "importAutoMatched ENTER matches=${matches.size}" } var linked = 0 var failed = 0 val now = nowMillis() @@ -269,7 +284,7 @@ class ExternalImportRepositoryImpl( lastReviewedAt = now, skipExpiresAt = null, ) - externalLinkDao.upsert( + upsertWithLog( base.copy( state = ExternalLinkState.MATCHED.name, repoOwner = top.owner, @@ -278,6 +293,7 @@ class ExternalImportRepositoryImpl( matchConfidence = top.confidence, lastReviewedAt = now, ), + callsite = "importAutoMatched", ) } outcome @@ -289,6 +305,7 @@ class ExternalImportRepositoryImpl( } } } + debug.i { "importAutoMatched EXIT linked=$linked failed=$failed" } runCatching { telemetry.importAutoLinked(countBucket = bucketCount(linked)) } .onFailure { Logger.d { "telemetry importAutoLinked failed: ${it.message}" } } return ImportSummary(attempted = matches.size, linked = linked, failed = failed) @@ -300,6 +317,7 @@ class ExternalImportRepositoryImpl( repo: String, source: String, ): Result { + debug.i { "linkManually pkg=$packageName repo=$owner/$repo source=$source" } val now = nowMillis() return runCatching { val existing = externalLinkDao.get(packageName) @@ -316,7 +334,7 @@ class ExternalImportRepositoryImpl( lastReviewedAt = now, skipExpiresAt = null, ) - externalLinkDao.upsert( + upsertWithLog( base.copy( state = ExternalLinkState.MATCHED.name, repoOwner = owner, @@ -325,6 +343,7 @@ class ExternalImportRepositoryImpl( matchConfidence = 1.0, lastReviewedAt = now, ), + callsite = "linkManually", ) }.onFailure { if (it is CancellationException) throw it } } @@ -333,6 +352,7 @@ class ExternalImportRepositoryImpl( packageName: String, neverAsk: Boolean, ) { + debug.i { "skipPackage pkg=$packageName neverAsk=$neverAsk" } val existing = externalLinkDao.get(packageName) val state = if (neverAsk) ExternalLinkState.NEVER_ASK else ExternalLinkState.SKIPPED val now = nowMillis() @@ -355,11 +375,12 @@ class ExternalImportRepositoryImpl( lastReviewedAt = now, skipExpiresAt = skipExpiresAt, ) - externalLinkDao.upsert(row) + upsertWithLog(row, callsite = "skipPackage") } override suspend fun unlink(packageName: String) { - externalLinkDao.deleteByPackageName(packageName) + debug.i { "unlink pkg=$packageName" } + deleteWithLog(packageName, callsite = "unlink") candidateSnapshot.update { it - packageName } } @@ -378,10 +399,11 @@ class ExternalImportRepositoryImpl( } override suspend fun restoreDecision(snapshot: ExternalDecisionSnapshot) { + debug.i { "restoreDecision pkg=${snapshot.packageName} state=${snapshot.state}" } val now = nowMillis() val state = snapshot.state ?: ExternalLinkState.PENDING_REVIEW val existing = externalLinkDao.get(snapshot.packageName) - externalLinkDao.upsert( + upsertWithLog( (existing ?: ExternalLinkEntity( packageName = snapshot.packageName, state = state.name, @@ -403,6 +425,7 @@ class ExternalImportRepositoryImpl( skipExpiresAt = snapshot.skipExpiresAt, lastReviewedAt = now, ), + callsite = "restoreDecision", ) } @@ -440,11 +463,13 @@ class ExternalImportRepositoryImpl( } override suspend fun syncSigningFingerprintSeed() { + debug.i { "syncSigningFingerprintSeed ENTER" } val started = nowMillis() var rowsAdded = 0 try { val lastObservedAt = runCatching { signingFingerprintDao.lastSyncTimestamp() } .getOrNull() + debug.i { "syncSigningFingerprintSeed lastObservedAt=$lastObservedAt" } var cursor: String? = null var pages = 0 paging@ while (pages < MAX_SEED_PAGES) { @@ -456,6 +481,7 @@ class ExternalImportRepositoryImpl( val response = pageResult.getOrElse { error -> if (error is CancellationException) throw error Logger.w(error) { "signing-seeds fetch failed on page $pages; aborting" } + debug.w(error) { "syncSigningFingerprintSeed page $pages fetch failed" } break@paging } val rows = response.rows.map { row -> @@ -481,7 +507,9 @@ class ExternalImportRepositoryImpl( throw e } catch (e: Exception) { Logger.w(e) { "signing-seeds sync aborted" } + debug.w(e) { "syncSigningFingerprintSeed aborted" } } + debug.i { "syncSigningFingerprintSeed EXIT rowsAdded=$rowsAdded durationMs=${nowMillis() - started}" } emitSeedSyncTelemetry(rowsAdded, nowMillis() - started) } @@ -615,6 +643,7 @@ class ExternalImportRepositoryImpl( } companion object { + const val E1_DEBUG_TAG = "E1Debug" private val INITIAL_SCAN_COMPLETED_AT_KEY = longPreferencesKey("external_import_initial_scan_at") private const val SKIP_TTL_MILLIS: Long = 7L * 24 * 60 * 60 * 1000 private const val MATCH_BATCH_SIZE = 25 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 a2ad86514..197f63c76 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 @@ -110,10 +110,10 @@ class AppsViewModel( count to dismissedAt } .collect { (count, dismissedAt) -> - // Banner re-shows only when the live count exceeds the count - // captured at dismiss time. Without this, every DAO emission - // (delta scan, package event) overrides the user's dismiss. val shouldShow = count >= BANNER_THRESHOLD && count > dismissedAt + logger.withTag("E1Debug").info( + "AppsViewModel emit count=$count dismissedAt=$dismissedAt show=$shouldShow", + ) _state.update { it.copy( pendingExternalImportCount = count, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 6774fe862..c95176dc0 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -67,6 +67,8 @@ class ExternalImportViewModel( private var searchJob: Job? = null private var pendingUndo: PendingUndo? = null + private val debug get() = logger.withTag("E1Debug") + private val _state = MutableStateFlow(ExternalImportState()) val state = _state @@ -189,17 +191,16 @@ class ExternalImportViewModel( private fun startScanIfIdle(force: Boolean = false) { if (!force && _state.value.phase != ImportPhase.Idle) return if (scanJob?.isActive == true) return + debug.info("VM startScanIfIdle force=$force") scanJob = viewModelScope.launch { try { _state.update { it.copy(phase = ImportPhase.Scanning, errorMessage = null) } - // runFullScan must precede pendingCandidatesFlow().first(): the in-memory candidate - // snapshot is process-scoped and empty on cold start, so without a scan first the - // wizard would render zero cards even with PENDING_REVIEW rows in the DAO. externalImportRepository.runFullScan() val candidates = externalImportRepository.pendingCandidatesFlow().first() candidatesByPackage = candidates.associateBy { it.packageName } + debug.info("VM after pendingCandidatesFlow.first(): ${candidates.size} candidates") _state.update { it.copy( @@ -210,7 +211,9 @@ class ExternalImportViewModel( val matches = externalImportRepository.resolveMatches(candidates) lastResolvedMatches = matches + debug.info("VM resolveMatches returned ${matches.size} results") val autoLinked = autoMaterialize(matches) + debug.info("VM autoMaterialize linked=${autoLinked.size} pkgs=$autoLinked") val autoLinkedPackages = autoLinked.toSet() val reviewCandidates = @@ -572,6 +575,7 @@ class ExternalImportViewModel( val current = _state.value if (current.phase != ImportPhase.AutoImportSummary) return val packages = current.autoLinkedPackages.toList() + debug.info("VM autoSummaryUndoAll packages=${packages.size}") if (packages.isEmpty()) { _state.update { it.copy(phase = ImportPhase.AwaitingReview) } return @@ -725,6 +729,7 @@ class ExternalImportViewModel( repo: String, source: String, ): Boolean { + debug.info("VM materializeAndMark pkg=${candidate.packageName} repo=$owner/$repo source=$source") val repoInfo = try { appsRepository.fetchRepoInfo(owner, repo) @@ -732,10 +737,12 @@ class ExternalImportViewModel( throw e } catch (e: Exception) { logger.error("fetchRepoInfo($owner/$repo) failed: ${e.message}") + debug.error("VM materializeAndMark fetchRepoInfo failed pkg=${candidate.packageName}", e) null } if (repoInfo == null) { logger.warn("Skipping link for ${candidate.packageName}: repo $owner/$repo not found") + debug.info("VM materializeAndMark FAILED pkg=${candidate.packageName} repo=$owner/$repo NOT FOUND") return false } @@ -746,8 +753,10 @@ class ExternalImportViewModel( throw e } catch (e: Exception) { logger.error("linkAppToRepo failed for ${candidate.packageName}: ${e.message}") + debug.error("VM materializeAndMark linkAppToRepo failed pkg=${candidate.packageName}", e) return false } + debug.info("VM materializeAndMark SUCCESS pkg=${candidate.packageName}") val linkResult = try { From 82315a975b3ed055fa4e250a44aeb4aef965e823 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 17:10:17 +0500 Subject: [PATCH 28/46] apps: switch + FAB to extended FAB with 'Add by link' label --- .../zed/rainxch/apps/presentation/AppsRoot.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 08e45b465..7b50315b7 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 @@ -48,7 +48,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearWavyProgressIndicator @@ -306,18 +306,20 @@ fun AppsScreen( ) }, floatingActionButton = { - FloatingActionButton( + ExtendedFloatingActionButton( onClick = { onAction(AppsAction.OnAddByLinkClick) }, + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + }, + text = { Text(stringResource(Res.string.add_by_link)) }, modifier = Modifier .navigationBarsPadding() .padding(bottom = bottomNavHeight), - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(Res.string.add_by_link), - ) - } + ) }, snackbarHost = { SnackbarHost( From c858b7cbafe5fe9a00201e89bb48dd4e11debb50 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:22:15 +0500 Subject: [PATCH 29/46] E1: address coderabbit review on PR #461 --- .../src/androidMain/AndroidManifest.xml | 1 + .../external/InstallerSourceClassifier.kt | 1 + .../external/SigningFingerprintComputer.kt | 16 +++--- .../dto/SigningFingerprintSeedResponse.kt | 4 ++ .../ExternalImportRepositoryImpl.kt | 53 ------------------- .../core/data/local/db/initDatabase.kt | 10 ++-- .../repository/ExternalImportRepository.kt | 3 -- .../domain/system/ExternalDecisionSnapshot.kt | 1 - .../rainxch/core/domain/system/ScanResult.kt | 6 --- .../PackageVisibilityRequester.android.kt | 12 +++-- .../apps/presentation/AppsViewModel.kt | 13 +++-- .../components/PermissionRationaleScreen.kt | 2 +- .../import/util/PackageVisibilityRequester.kt | 3 +- .../util/PackageVisibilityRequester.jvm.kt | 3 +- 14 files changed, 41 insertions(+), 87 deletions(-) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 383f5b145..09d159ecb 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -27,6 +27,7 @@ + diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt index 4b7764e2a..0bf314e7b 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt @@ -65,6 +65,7 @@ class InstallerSourceClassifier( setOf( "dev.imranr.obtainium", "dev.imranr.obtainium.app", + "dev.imranr.obtainium.fdroid", ) private const val FDROID = "org.fdroid.fdroid" private const val PLAY = "com.android.vending" diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt index 304890a2b..07cee9b15 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt @@ -44,14 +44,14 @@ object SigningFingerprintComputer { private fun certBytes(info: PackageInfo): ByteArray? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val sigInfo = info.signingInfo - val certs = - if (sigInfo?.hasMultipleSigners() == true) { - sigInfo.apkContentsSigners - } else { - sigInfo?.signingCertificateHistory - } - certs?.firstOrNull()?.toByteArray() + val sigInfo = info.signingInfo ?: return null + // Prefer the active signer set. signingCertificateHistory is ordered with + // the original cert at index 0 and the current cert at the last index, so + // .firstOrNull() would return the pre-rotation cert for v3-rotated apps + // and break fingerprint matching against signed binaries. + val current = sigInfo.apkContentsSigners?.firstOrNull() + val fallback = sigInfo.signingCertificateHistory?.lastOrNull() + (current ?: fallback)?.toByteArray() } else { @Suppress("DEPRECATION") info.signatures?.firstOrNull()?.toByteArray() diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt index 83b8114ec..7232fb602 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt @@ -12,6 +12,10 @@ data class SigningFingerprintSeedResponse( val fingerprint: String, val owner: String, val repo: String, + // Epoch milliseconds. Backend contract (E1 plan §7.4); the same value is + // forwarded as `since` on the next sync — any unit drift between client + // and backend would show up as either re-fetched pages or a hard skip, + // which the seed-sync telemetry will surface. val observedAt: Long, ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 1308c9f9f..0e8fe02ef 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -29,7 +29,6 @@ import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.ExternalDecisionSnapshot import zed.rainxch.core.domain.system.ExternalLinkState -import zed.rainxch.core.domain.system.ImportSummary import zed.rainxch.core.domain.system.RepoMatchResult import zed.rainxch.core.domain.system.RepoMatchSource import zed.rainxch.core.domain.system.RepoMatchSuggestion @@ -261,56 +260,6 @@ class ExternalImportRepositoryImpl( } } - override suspend fun importAutoMatched(matches: List): ImportSummary { - debug.i { "importAutoMatched ENTER matches=${matches.size}" } - var linked = 0 - var failed = 0 - val now = nowMillis() - matches.forEach { result -> - val top = result.topSuggestion - if (top != null && top.confidence >= AUTO_LINK_CONFIDENCE_THRESHOLD) { - val outcome = runCatching { - val existing = externalLinkDao.get(result.packageName) - val base = existing ?: ExternalLinkEntity( - packageName = result.packageName, - state = ExternalLinkState.MATCHED.name, - repoOwner = top.owner, - repoName = top.repo, - matchSource = top.source.name.lowercase(), - matchConfidence = top.confidence, - signingFingerprint = null, - installerKind = null, - firstSeenAt = now, - lastReviewedAt = now, - skipExpiresAt = null, - ) - upsertWithLog( - base.copy( - state = ExternalLinkState.MATCHED.name, - repoOwner = top.owner, - repoName = top.repo, - matchSource = top.source.name.lowercase(), - matchConfidence = top.confidence, - lastReviewedAt = now, - ), - callsite = "importAutoMatched", - ) - } - outcome - .onSuccess { linked++ } - .onFailure { e -> - if (e is CancellationException) throw e - failed++ - Logger.w(e) { "auto-link upsert failed for ${result.packageName}" } - } - } - } - debug.i { "importAutoMatched EXIT linked=$linked failed=$failed" } - runCatching { telemetry.importAutoLinked(countBucket = bucketCount(linked)) } - .onFailure { Logger.d { "telemetry importAutoLinked failed: ${it.message}" } } - return ImportSummary(attempted = matches.size, linked = linked, failed = failed) - } - override suspend fun linkManually( packageName: String, owner: String, @@ -394,7 +343,6 @@ class ExternalImportRepositoryImpl( matchSource = row.matchSource, matchConfidence = row.matchConfidence, skipExpiresAt = row.skipExpiresAt, - hadInstalledAppRow = false, ) } @@ -647,7 +595,6 @@ class ExternalImportRepositoryImpl( private val INITIAL_SCAN_COMPLETED_AT_KEY = longPreferencesKey("external_import_initial_scan_at") private const val SKIP_TTL_MILLIS: Long = 7L * 24 * 60 * 60 * 1000 private const val MATCH_BATCH_SIZE = 25 - private const val AUTO_LINK_CONFIDENCE_THRESHOLD = 0.85 private const val FINGERPRINT_CONFIDENCE = 0.92 private const val SEARCH_OVERRIDE_CONFIDENCE = 0.5 private const val SEARCH_LIMIT = 10 diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt index 109565888..f2c770225 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt @@ -7,11 +7,13 @@ import zed.rainxch.core.data.local.DesktopAppDataPaths import java.io.File fun initDatabase(): AppDatabase { - // SQLite WAL mode keeps two side files alongside the .db; migrate all three - // so an upgrade preserves any uncommitted transactions in the WAL. - DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db") - DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db-shm") + // SQLite WAL mode keeps two side files alongside the .db. Migrate sidecars + // FIRST so the .db never lands at the new location without its WAL — if + // the WAL/SHM copies fail, we abort before touching the .db and let the + // user retry next launch (the legacy files are still in tmp). DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db-wal") + DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db-shm") + DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db") val dbFile = File(DesktopAppDataPaths.appDataDir(), "github_store.db") return Room diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt index edd09b642..6f595029a 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt @@ -3,7 +3,6 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalDecisionSnapshot -import zed.rainxch.core.domain.system.ImportSummary import zed.rainxch.core.domain.system.RepoMatchResult import zed.rainxch.core.domain.system.ScanResult @@ -20,8 +19,6 @@ interface ExternalImportRepository { suspend fun resolveMatches(candidates: List): List - suspend fun importAutoMatched(matches: List): ImportSummary - suspend fun linkManually( packageName: String, owner: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt index 185933c4f..4369a0697 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt @@ -8,5 +8,4 @@ data class ExternalDecisionSnapshot( val matchSource: String?, val matchConfidence: Double?, val skipExpiresAt: Long?, - val hadInstalledAppRow: Boolean, ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt index 61cfa2844..c02703a1f 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt @@ -8,9 +8,3 @@ data class ScanResult( val durationMillis: Long, val permissionGranted: Boolean, ) - -data class ImportSummary( - val attempted: Int, - val linked: Int, - val failed: Int, -) diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt index 9d2234052..90c93837c 100644 --- a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt @@ -8,6 +8,8 @@ import android.provider.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext // keep in sync with AndroidExternalAppScanner.GRANT_THRESHOLD private const val GRANT_THRESHOLD = 30 @@ -21,12 +23,14 @@ actual fun rememberPackageVisibilityRequester(): PackageVisibilityRequester { private class AndroidPackageVisibilityRequester( private val context: Context, ) : PackageVisibilityRequester { - override val isGranted: Boolean - get() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return true + // pm.getInstalledPackages is a binder IPC and can take noticeable time on devices + // with many packages — keep it off the main thread. + override suspend fun isGranted(): Boolean = + withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return@withContext true val pm = context.packageManager val visible = runCatching { pm.getInstalledPackages(0) }.getOrElse { emptyList() } - return visible.size >= GRANT_THRESHOLD + visible.size >= GRANT_THRESHOLD } override suspend fun requestOrOpenSettings(): Boolean { 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 197f63c76..1f384d941 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 @@ -469,13 +469,16 @@ class AppsViewModel( } AppsAction.OnImportProposalReview -> { + // Snapshot the current count to the dismiss watermark BEFORE the + // navigation event is sent. Symmetric with OnImportProposalDismiss + // and avoids a race where observePendingExternalImports reads a + // stale watermark while we're navigating and re-flashes the banner. + // After the wizard runs, count drops (auto-import) or stays > the + // snapshotted watermark only if NEW candidates appeared post-Review. + val current = _state.value.pendingExternalImportCount _state.update { it.copy(showImportProposalBanner = false) } viewModelScope.launch { - // Reviewing implies the user is acting on the current set, - // so wipe the dismiss watermark — banner can re-show next - // time as count comes back >0 (or stays >0 if any cards - // remained un-decided). - runCatching { tweaksRepository.setExternalImportBannerDismissedAtCount(0) } + runCatching { tweaksRepository.setExternalImportBannerDismissedAtCount(current) } _events.send(AppsEvent.NavigateToExternalImport) } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt index 6bf0a56db..8d98e1622 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt @@ -80,7 +80,7 @@ fun PermissionRationaleScreen( Button(onClick = { scope.launch { onAction(ExternalImportAction.OnRequestPermission) - if (requester.isGranted) { + if (requester.isGranted()) { onAction(ExternalImportAction.OnPermissionGranted(sdkInt)) } else { requester.requestOrOpenSettings() diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt index f8d210a2b..822b21bf8 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable expect fun rememberPackageVisibilityRequester(): PackageVisibilityRequester interface PackageVisibilityRequester { - val isGranted: Boolean + suspend fun isGranted(): Boolean + suspend fun requestOrOpenSettings(): Boolean } diff --git a/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt index f3d2f3a5c..d997981e3 100644 --- a/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt +++ b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt @@ -8,6 +8,7 @@ actual fun rememberPackageVisibilityRequester(): PackageVisibilityRequester = remember { JvmPackageVisibilityRequester } private object JvmPackageVisibilityRequester : PackageVisibilityRequester { - override val isGranted: Boolean = true + override suspend fun isGranted(): Boolean = true + override suspend fun requestOrOpenSettings(): Boolean = true } From ad313fdba2266985dd8720c2f53433675f14affc Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:44:15 +0500 Subject: [PATCH 30/46] E1: runDeltaScan preserves committed decisions on transient evidence miss --- .../ExternalImportRepositoryImpl.kt | 71 ++++++++----------- 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 0e8fe02ef..54d208354 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -49,17 +49,6 @@ class ExternalImportRepositoryImpl( // next scan rather than persisted to keep the schema small. private val candidateSnapshot = MutableStateFlow>(emptyMap()) - private val debug = Logger.withTag(E1_DEBUG_TAG) - - private suspend fun upsertWithLog(row: ExternalLinkEntity, callsite: String) { - externalLinkDao.upsert(row) - debug.i { "upsert from=$callsite pkg=${row.packageName} state=${row.state} repo=${row.repoOwner}/${row.repoName}" } - } - - private suspend fun deleteWithLog(packageName: String, callsite: String) { - externalLinkDao.deleteByPackageName(packageName) - debug.i { "delete from=$callsite pkg=$packageName" } - } override fun pendingCandidatesFlow(): Flow> = combine( @@ -74,7 +63,6 @@ class ExternalImportRepositoryImpl( override suspend fun scheduleInitialScanIfNeeded() { val firstLaunch = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] == null - debug.i { "scheduleInitialScanIfNeeded enter firstLaunch=$firstLaunch" } runCatching { if (firstLaunch) { runCatching { telemetry.importScanStarted(trigger = "first_launch") } @@ -83,21 +71,16 @@ class ExternalImportRepositoryImpl( runFullScan() }.onSuccess { if (firstLaunch) markInitialScanComplete() - debug.i { "scheduleInitialScanIfNeeded done firstLaunch=$firstLaunch" } }.onFailure { - debug.w(it) { "scheduleInitialScanIfNeeded FAILED" } Logger.w(it) { "External scan failed; will retry on next launch." } } } override suspend fun runFullScan(): ScanResult { val started = nowMillis() - debug.i { "runFullScan ENTER" } val granted = scanner.isPermissionGranted() val rawCandidates = scanner.snapshot() - debug.i { "runFullScan scanner.snapshot returned ${rawCandidates.size} raw candidates (granted=$granted)" } val candidates = rawCandidates.filter { hasPositiveEvidence(it) } - debug.i { "runFullScan after positive-evidence filter: ${candidates.size} kept (dropped ${rawCandidates.size - candidates.size})" } candidateSnapshot.update { candidates.associateBy { it.packageName } } val now = nowMillis() @@ -111,13 +94,11 @@ class ExternalImportRepositoryImpl( if (existing == null) newCandidates++ if (updated.state == ExternalLinkState.PENDING_REVIEW.name) pendingReview++ if (existing != null && updated.state != ExternalLinkState.PENDING_REVIEW.name) preservedDecisions++ - upsertWithLog(updated, callsite = "runFullScan") + externalLinkDao.upsert(updated) } - debug.i { "runFullScan upserted: new=$newCandidates pendingReview=$pendingReview preservedDecisions=$preservedDecisions" } val livePackages = candidates.map { it.packageName }.toSet() runCatching { externalLinkDao.prunePendingReviewNotIn(livePackages) } - .onSuccess { debug.i { "runFullScan prunePendingReviewNotIn livePackages=${livePackages.size}" } } .onFailure { Logger.d { "prune pending failed: ${it.message}" } } val durationMs = nowMillis() - started @@ -139,7 +120,6 @@ class ExternalImportRepositoryImpl( } override suspend fun runDeltaScan(changedPackageNames: Set): ScanResult { - debug.i { "runDeltaScan ENTER changed=${changedPackageNames.size} packages=$changedPackageNames" } val started = nowMillis() val granted = scanner.isPermissionGranted() val now = nowMillis() @@ -149,19 +129,36 @@ class ExternalImportRepositoryImpl( changedPackageNames.forEach { pkg -> val rawCandidate = scanner.snapshotSingle(pkg) - val candidate = rawCandidate?.takeIf { hasPositiveEvidence(it) } - if (candidate == null) { - deleteWithLog(pkg, callsite = "runDeltaScan(no-evidence-or-uninstalled)") + val existing = externalLinkDao.get(pkg) + + if (rawCandidate == null) { + // Package is genuinely gone (uninstalled). Hard-delete the row. + if (existing != null) { + externalLinkDao.deleteByPackageName(pkg) + } return@forEach } + + if (!hasPositiveEvidence(rawCandidate)) { + // Package exists but currently has no positive evidence. Only + // drop PENDING_REVIEW rows here — preserved decisions + // (MATCHED / NEVER_ASK / SKIPPED) must survive a transient + // evidence miss (e.g., F-Droid seed not yet synced, manifest + // hint changed across reinstall). Deleting them on a single + // transient miss would silently wipe the user's decisions. + if (existing?.state == ExternalLinkState.PENDING_REVIEW.name) { + externalLinkDao.deleteByPackageName(pkg) + } + return@forEach + } + + val candidate = rawCandidate deltaCandidates += candidate - val existing = externalLinkDao.get(pkg) val updated = mergeCandidate(existing, candidate, now) if (existing == null) newCandidates++ if (updated.state == ExternalLinkState.PENDING_REVIEW.name) pendingReview++ - upsertWithLog(updated, callsite = "runDeltaScan") + externalLinkDao.upsert(updated) } - debug.i { "runDeltaScan upserted: new=$newCandidates pendingReview=$pendingReview deltaSize=${deltaCandidates.size}" } if (deltaCandidates.isNotEmpty()) { candidateSnapshot.update { current -> @@ -266,7 +263,6 @@ class ExternalImportRepositoryImpl( repo: String, source: String, ): Result { - debug.i { "linkManually pkg=$packageName repo=$owner/$repo source=$source" } val now = nowMillis() return runCatching { val existing = externalLinkDao.get(packageName) @@ -283,7 +279,7 @@ class ExternalImportRepositoryImpl( lastReviewedAt = now, skipExpiresAt = null, ) - upsertWithLog( + externalLinkDao.upsert( base.copy( state = ExternalLinkState.MATCHED.name, repoOwner = owner, @@ -292,7 +288,6 @@ class ExternalImportRepositoryImpl( matchConfidence = 1.0, lastReviewedAt = now, ), - callsite = "linkManually", ) }.onFailure { if (it is CancellationException) throw it } } @@ -301,7 +296,6 @@ class ExternalImportRepositoryImpl( packageName: String, neverAsk: Boolean, ) { - debug.i { "skipPackage pkg=$packageName neverAsk=$neverAsk" } val existing = externalLinkDao.get(packageName) val state = if (neverAsk) ExternalLinkState.NEVER_ASK else ExternalLinkState.SKIPPED val now = nowMillis() @@ -324,12 +318,11 @@ class ExternalImportRepositoryImpl( lastReviewedAt = now, skipExpiresAt = skipExpiresAt, ) - upsertWithLog(row, callsite = "skipPackage") + externalLinkDao.upsert(row) } override suspend fun unlink(packageName: String) { - debug.i { "unlink pkg=$packageName" } - deleteWithLog(packageName, callsite = "unlink") + externalLinkDao.deleteByPackageName(packageName) candidateSnapshot.update { it - packageName } } @@ -347,11 +340,10 @@ class ExternalImportRepositoryImpl( } override suspend fun restoreDecision(snapshot: ExternalDecisionSnapshot) { - debug.i { "restoreDecision pkg=${snapshot.packageName} state=${snapshot.state}" } val now = nowMillis() val state = snapshot.state ?: ExternalLinkState.PENDING_REVIEW val existing = externalLinkDao.get(snapshot.packageName) - upsertWithLog( + externalLinkDao.upsert( (existing ?: ExternalLinkEntity( packageName = snapshot.packageName, state = state.name, @@ -373,7 +365,6 @@ class ExternalImportRepositoryImpl( skipExpiresAt = snapshot.skipExpiresAt, lastReviewedAt = now, ), - callsite = "restoreDecision", ) } @@ -411,13 +402,11 @@ class ExternalImportRepositoryImpl( } override suspend fun syncSigningFingerprintSeed() { - debug.i { "syncSigningFingerprintSeed ENTER" } val started = nowMillis() var rowsAdded = 0 try { val lastObservedAt = runCatching { signingFingerprintDao.lastSyncTimestamp() } .getOrNull() - debug.i { "syncSigningFingerprintSeed lastObservedAt=$lastObservedAt" } var cursor: String? = null var pages = 0 paging@ while (pages < MAX_SEED_PAGES) { @@ -429,7 +418,6 @@ class ExternalImportRepositoryImpl( val response = pageResult.getOrElse { error -> if (error is CancellationException) throw error Logger.w(error) { "signing-seeds fetch failed on page $pages; aborting" } - debug.w(error) { "syncSigningFingerprintSeed page $pages fetch failed" } break@paging } val rows = response.rows.map { row -> @@ -455,9 +443,7 @@ class ExternalImportRepositoryImpl( throw e } catch (e: Exception) { Logger.w(e) { "signing-seeds sync aborted" } - debug.w(e) { "syncSigningFingerprintSeed aborted" } } - debug.i { "syncSigningFingerprintSeed EXIT rowsAdded=$rowsAdded durationMs=${nowMillis() - started}" } emitSeedSyncTelemetry(rowsAdded, nowMillis() - started) } @@ -591,7 +577,6 @@ class ExternalImportRepositoryImpl( } companion object { - const val E1_DEBUG_TAG = "E1Debug" private val INITIAL_SCAN_COMPLETED_AT_KEY = longPreferencesKey("external_import_initial_scan_at") private const val SKIP_TTL_MILLIS: Long = 7L * 24 * 60 * 60 * 1000 private const val MATCH_BATCH_SIZE = 25 From 3b2bb378e3de81bd8335ce04c5b48678295e4ba3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:44:15 +0500 Subject: [PATCH 31/46] E1: stop optimistic permission grant; drop misleading settings deep-link --- .../PackageVisibilityRequester.android.kt | 24 +++++++------------ .../components/PermissionRationaleScreen.kt | 17 +++++++------ 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt index 90c93837c..1bd88316c 100644 --- a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt @@ -1,10 +1,7 @@ package zed.rainxch.apps.presentation.import.util import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Build -import android.provider.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -34,19 +31,14 @@ private class AndroidPackageVisibilityRequester( } override suspend fun requestOrOpenSettings(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return true - // QUERY_ALL_PACKAGES is a "special app access" permission as of API 30 — there - // is no native runtime dialog. Best we can do is land the user on the App Info - // page where the toggle lives. We can't observe grant from here; the caller - // re-checks `isGranted` after the user returns. - val intent = - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", context.packageName, null), - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - runCatching { context.startActivity(intent) } + // No-op by contract. QUERY_ALL_PACKAGES is granted at install time + // via the manifest declaration — there is no user-grantable runtime + // toggle on stock Android, and `ACTION_APPLICATION_DETAILS_SETTINGS` + // does not surface the (non-existent) toggle either. Some OEMs + // (Samsung One UI 4+) expose a "Special access → Allow access to all + // apps" setting, but no public Intent reliably deep-links to it. + // Callers must rely on `isGranted()` and gracefully degrade when + // false; the scanner's heuristic-based degraded path handles this. return false } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt index 8d98e1622..05006b63e 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt @@ -80,15 +80,18 @@ fun PermissionRationaleScreen( Button(onClick = { scope.launch { onAction(ExternalImportAction.OnRequestPermission) - if (requester.isGranted()) { - onAction(ExternalImportAction.OnPermissionGranted(sdkInt)) + // QUERY_ALL_PACKAGES is install-time only on stock Android. + // Either we already have visibility (manifest grant honoured) + // → proceed Granted; or we don't → proceed Denied and let the + // scanner's heuristic-degraded path handle it. We never + // dispatch Granted optimistically — that would lie to + // telemetry and skip the degraded-path UX in EmptyStateScreen. + val action = if (requester.isGranted()) { + ExternalImportAction.OnPermissionGranted(sdkInt) } else { - requester.requestOrOpenSettings() - // We can't auto-confirm grant from a settings deep-link. - // Optimistically advance — the scanner's degraded path - // handles the actual visibility outcome. - onAction(ExternalImportAction.OnPermissionGranted(sdkInt)) + ExternalImportAction.OnPermissionDenied(sdkInt) } + onAction(action) } }) { Text(stringResource(Res.string.external_import_permission_continue)) From f04c0da13803ce554d52ba9b84d42a1d42ad555f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:44:15 +0500 Subject: [PATCH 32/46] E1: synchronous local watermark to fully close banner re-flash race --- .../apps/presentation/AppsViewModel.kt | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) 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 1f384d941..9c5a3543d 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 @@ -72,6 +72,14 @@ class AppsViewModel( private var updateAllJob: Job? = null private var lastAutoCheckTimestamp: Long = 0L + // Synchronous mirror of the persisted dismiss watermark. The persisted + // write goes through DataStore async, so the import-banner flow could + // re-emit with the OLD watermark and re-flash the banner before the + // write completes. This in-memory shadow is set BEFORE the launch and + // OR'd into shouldShow so the suppression is immediate. + @Volatile + private var localBannerDismissedAtCount: Int = 0 + /** Debounced re-runs of the live preview in the advanced settings sheet. */ private var advancedPreviewJob: Job? = null @@ -110,10 +118,12 @@ class AppsViewModel( count to dismissedAt } .collect { (count, dismissedAt) -> - val shouldShow = count >= BANNER_THRESHOLD && count > dismissedAt - logger.withTag("E1Debug").info( - "AppsViewModel emit count=$count dismissedAt=$dismissedAt show=$shouldShow", - ) + // The effective watermark is the max of the persisted value and the + // synchronous local one. The local shadow is set immediately by the + // Review/Dismiss handlers so a flow emission that beats the DataStore + // write still sees a watermark that suppresses the banner. + val effectiveDismissedAt = maxOf(dismissedAt, localBannerDismissedAtCount) + val shouldShow = count >= BANNER_THRESHOLD && count > effectiveDismissedAt _state.update { it.copy( pendingExternalImportCount = count, @@ -469,13 +479,12 @@ class AppsViewModel( } AppsAction.OnImportProposalReview -> { - // Snapshot the current count to the dismiss watermark BEFORE the - // navigation event is sent. Symmetric with OnImportProposalDismiss - // and avoids a race where observePendingExternalImports reads a - // stale watermark while we're navigating and re-flashes the banner. - // After the wizard runs, count drops (auto-import) or stays > the - // snapshotted watermark only if NEW candidates appeared post-Review. val current = _state.value.pendingExternalImportCount + // Set the local watermark BEFORE the launch so a racing flow + // emission can't recompute shouldShow=true on the stale persisted + // watermark. observePendingExternalImports OR's this with the + // persisted value via maxOf(). + localBannerDismissedAtCount = maxOf(localBannerDismissedAtCount, current) _state.update { it.copy(showImportProposalBanner = false) } viewModelScope.launch { runCatching { tweaksRepository.setExternalImportBannerDismissedAtCount(current) } @@ -485,11 +494,24 @@ class AppsViewModel( AppsAction.OnImportProposalDismiss -> { val current = _state.value.pendingExternalImportCount + localBannerDismissedAtCount = maxOf(localBannerDismissedAtCount, current) _state.update { it.copy(showImportProposalBanner = false) } viewModelScope.launch { runCatching { tweaksRepository.setExternalImportBannerDismissedAtCount(current) } } } + + AppsAction.OnRescanForGithubApps -> { + // Manual rescan resets the banner watermark so the user sees + // everything fresh; the wizard's startScanIfIdle then runs + // a full scan + match cycle on entry. + localBannerDismissedAtCount = 0 + _state.update { it.copy(showImportProposalBanner = false) } + viewModelScope.launch { + runCatching { tweaksRepository.setExternalImportBannerDismissedAtCount(0) } + _events.send(AppsEvent.NavigateToExternalImport) + } + } } } From ac5a1d31178ae8aecef60ff0afa9695cf45d299b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:44:15 +0500 Subject: [PATCH 33/46] E1: drop E1Debug instrumentation --- .../zed/rainxch/githubstore/app/GithubStoreApp.kt | 4 ---- .../core/data/services/PackageEventReceiver.kt | 3 --- .../rainxch/core/data/services/UpdateCheckWorker.kt | 7 ------- .../presentation/import/ExternalImportViewModel.kt | 12 ------------ 4 files changed, 26 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 551090a03..e85818408 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -45,24 +45,20 @@ class GithubStoreApp : Application() { } private fun scheduleInitialExternalScan() { - Logger.withTag("E1Debug").i { "GithubStoreApp scheduleInitialExternalScan invoked" } appScope.launch { runCatching { get().scheduleInitialScanIfNeeded() }.onFailure { - Logger.withTag("E1Debug").w(it) { "GithubStoreApp scheduleInitialExternalScan FAILED" } Logger.w(it) { "Initial external scan scheduling failed" } } } } private fun scheduleSigningSeedSync() { - Logger.withTag("E1Debug").i { "GithubStoreApp scheduleSigningSeedSync invoked" } appScope.launch { runCatching { get().syncSigningFingerprintSeed() }.onFailure { - Logger.withTag("E1Debug").w(it) { "GithubStoreApp scheduleSigningSeedSync FAILED" } Logger.w(it) { "Signing seed sync failed" } } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index 2ad4adae0..4e087f7cb 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -88,7 +88,6 @@ class PackageEventReceiver() : val packageName = intent?.data?.schemeSpecificPart ?: return Logger.d { "PackageEventReceiver: ${intent.action} for $packageName" } - Logger.withTag("E1Debug").i { "PackageEventReceiver action=${intent.action} pkg=$packageName" } try { when (intent.action) { @@ -171,12 +170,10 @@ class PackageEventReceiver() : getBackstopScope().launch { runCatching { val rescan = shouldRescan(packageName) - Logger.withTag("E1Debug").i { "PackageEventReceiver shouldRescan pkg=$packageName -> $rescan" } if (rescan) { getExternalImport().runDeltaScan(setOf(packageName)) } }.onFailure { - Logger.withTag("E1Debug").w(it) { "PackageEventReceiver delta scan failed pkg=$packageName" } Logger.w(it) { "Delta scan failed for $packageName" } } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index b752448b0..9b4f05fdc 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -50,7 +50,6 @@ class UpdateCheckWorker( override suspend fun doWork(): Result = try { Logger.i { "UpdateCheckWorker: Starting periodic update check" } - Logger.withTag("E1Debug").i { "UpdateCheckWorker doWork ENTER attempt=$runAttemptCount" } // Run as foreground service to prevent OS from killing the worker setForeground(createForegroundInfo("Checking for updates...")) @@ -101,11 +100,9 @@ class UpdateCheckWorker( // broadcast we missed (process killed, OEM app-standby, etc.). // Cap at 50 so a 200-package device doesn't drag the worker. private suspend fun runPeriodicExternalDeltaScan() { - Logger.withTag("E1Debug").i { "UpdateCheckWorker runPeriodicExternalDeltaScan ENTER" } try { val installed = packageMonitor.getAllInstalledPackageNames() if (installed.isEmpty()) { - Logger.withTag("E1Debug").i { "UpdateCheckWorker delta installed=0 EXIT" } return } @@ -118,9 +115,6 @@ class UpdateCheckWorker( ).toSet() val delta = (installed - tracked - permanent).take(MAX_DELTA_PACKAGES).toSet() - Logger.withTag("E1Debug").i { - "UpdateCheckWorker delta installed=${installed.size} tracked=${tracked.size} permanent=${permanent.size} -> delta=${delta.size}" - } if (delta.isEmpty()) { Logger.d { "UpdateCheckWorker: external delta scan empty" } return @@ -130,7 +124,6 @@ class UpdateCheckWorker( externalImportRepository.runDeltaScan(delta) } catch (e: Exception) { Logger.w { "UpdateCheckWorker: external delta scan failed: ${e.message}" } - Logger.withTag("E1Debug").w(e) { "UpdateCheckWorker delta FAILED" } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index c95176dc0..c87f90cac 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -67,8 +67,6 @@ class ExternalImportViewModel( private var searchJob: Job? = null private var pendingUndo: PendingUndo? = null - private val debug get() = logger.withTag("E1Debug") - private val _state = MutableStateFlow(ExternalImportState()) val state = _state @@ -191,7 +189,6 @@ class ExternalImportViewModel( private fun startScanIfIdle(force: Boolean = false) { if (!force && _state.value.phase != ImportPhase.Idle) return if (scanJob?.isActive == true) return - debug.info("VM startScanIfIdle force=$force") scanJob = viewModelScope.launch { try { _state.update { it.copy(phase = ImportPhase.Scanning, errorMessage = null) } @@ -200,7 +197,6 @@ class ExternalImportViewModel( val candidates = externalImportRepository.pendingCandidatesFlow().first() candidatesByPackage = candidates.associateBy { it.packageName } - debug.info("VM after pendingCandidatesFlow.first(): ${candidates.size} candidates") _state.update { it.copy( @@ -211,9 +207,7 @@ class ExternalImportViewModel( val matches = externalImportRepository.resolveMatches(candidates) lastResolvedMatches = matches - debug.info("VM resolveMatches returned ${matches.size} results") val autoLinked = autoMaterialize(matches) - debug.info("VM autoMaterialize linked=${autoLinked.size} pkgs=$autoLinked") val autoLinkedPackages = autoLinked.toSet() val reviewCandidates = @@ -575,7 +569,6 @@ class ExternalImportViewModel( val current = _state.value if (current.phase != ImportPhase.AutoImportSummary) return val packages = current.autoLinkedPackages.toList() - debug.info("VM autoSummaryUndoAll packages=${packages.size}") if (packages.isEmpty()) { _state.update { it.copy(phase = ImportPhase.AwaitingReview) } return @@ -729,7 +722,6 @@ class ExternalImportViewModel( repo: String, source: String, ): Boolean { - debug.info("VM materializeAndMark pkg=${candidate.packageName} repo=$owner/$repo source=$source") val repoInfo = try { appsRepository.fetchRepoInfo(owner, repo) @@ -737,12 +729,10 @@ class ExternalImportViewModel( throw e } catch (e: Exception) { logger.error("fetchRepoInfo($owner/$repo) failed: ${e.message}") - debug.error("VM materializeAndMark fetchRepoInfo failed pkg=${candidate.packageName}", e) null } if (repoInfo == null) { logger.warn("Skipping link for ${candidate.packageName}: repo $owner/$repo not found") - debug.info("VM materializeAndMark FAILED pkg=${candidate.packageName} repo=$owner/$repo NOT FOUND") return false } @@ -753,10 +743,8 @@ class ExternalImportViewModel( throw e } catch (e: Exception) { logger.error("linkAppToRepo failed for ${candidate.packageName}: ${e.message}") - debug.error("VM materializeAndMark linkAppToRepo failed pkg=${candidate.packageName}", e) return false } - debug.info("VM materializeAndMark SUCCESS pkg=${candidate.packageName}") val linkResult = try { From 413136e45ec32a8fe7c4a38961d772dcb7387e58 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:44:15 +0500 Subject: [PATCH 34/46] apps: 'Scan for GitHub apps' overflow entry triggers manual rescan --- .../commonMain/composeResources/values/strings.xml | 1 + .../zed/rainxch/apps/presentation/AppsAction.kt | 5 +++++ .../kotlin/zed/rainxch/apps/presentation/AppsRoot.kt | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 310dfb282..bc5535fcb 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -851,6 +851,7 @@ +%1$d more Don\'t see your app? Add manually Add an app from another store + Scan for GitHub apps Unlink from this repo 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 607de0428..411f6d10d 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 @@ -105,4 +105,9 @@ sealed interface AppsAction { data object OnImportProposalReview : AppsAction data object OnImportProposalDismiss : AppsAction + + // Manual rescan trigger from the apps screen overflow. Resets the banner + // dismiss watermark and routes the user into the import wizard, which + // runs a fresh scan + match resolution on entry. + data object OnRescanForGithubApps : 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 7b50315b7..d8787351c 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 @@ -37,6 +37,7 @@ import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -122,6 +123,7 @@ import zed.rainxch.githubstore.core.presentation.res.currently_updating import zed.rainxch.githubstore.core.presentation.res.downloading import zed.rainxch.githubstore.core.presentation.res.error_with_message import zed.rainxch.githubstore.core.presentation.res.export_apps +import zed.rainxch.githubstore.core.presentation.res.external_import_rescan_menu import zed.rainxch.githubstore.core.presentation.res.import_apps import zed.rainxch.githubstore.core.presentation.res.installed_apps import zed.rainxch.githubstore.core.presentation.res.installing @@ -300,6 +302,16 @@ fun AppsScreen( Icon(Icons.Outlined.FileDownload, contentDescription = null) }, ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.external_import_rescan_menu)) }, + onClick = { + showOverflowMenu = false + onAction(AppsAction.OnRescanForGithubApps) + }, + leadingIcon = { + Icon(Icons.Outlined.Search, contentDescription = null) + }, + ) } } }, From b5d5731915dc911b8f07d13dacdf3a2f95308d3f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:44:15 +0500 Subject: [PATCH 35/46] apps: union the apps-tab badge with pending external-import count --- .../rainxch/githubstore/app/navigation/AppNavigation.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index be3fec905..9c1be9b2a 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -373,7 +373,13 @@ fun AppNavigation( restoreState = true } }, - isUpdateAvailable = appsState.apps.any { it.installedApp.isUpdateAvailable }, + // Badge fires when either an update is waiting OR pending + // import candidates need review. The badge is a single dot + // — a union of the two conditions is honest "you have + // something to look at on this tab". + isUpdateAvailable = + appsState.apps.any { it.installedApp.isUpdateAvailable } || + appsState.showImportProposalBanner, isLiquidGlassEnabled = isLiquidGlassEnabled, modifier = Modifier From f02ec950707c1eb85eefdfd21384188eb16be254 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:44:20 +0500 Subject: [PATCH 36/46] E1: backend handoff doc --- roadmap/E1_BACKEND_HANDOFF.md | 187 ++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 roadmap/E1_BACKEND_HANDOFF.md diff --git a/roadmap/E1_BACKEND_HANDOFF.md b/roadmap/E1_BACKEND_HANDOFF.md new file mode 100644 index 000000000..2950b4704 --- /dev/null +++ b/roadmap/E1_BACKEND_HANDOFF.md @@ -0,0 +1,187 @@ +# E1 — backend handoff + +Status: client-side complete on `feature/e1-external-imports` (PR #461). Two backend endpoints required before E1 reaches GA. One consolidation question with E6. + +--- + +## What the client expects + +### 1. `POST /v1/external-match` — required + +Match a batch of installed-app fingerprints to GitHub repos. Anonymous (no `X-GitHub-Token`). Used by the wizard's match-resolution pipeline; today the client mocks this endpoint behind the `tweaks.externalMatchSearchEnabled` flag (default `false`). + +**Request:** + +```http +POST /v1/external-match +Content-Type: application/json + +{ + "platform": "android", + "candidates": [ + { + "packageName": "com.example.foo", + "appLabel": "Foo App", + "signingFingerprint": "AB:CD:EF:...", + "installerKind": "browser", + "manifestHint": { "owner": null, "repo": null } + } + ] +} +``` + +| Field | Type | Required | Constraints | +| --- | --- | --- | --- | +| `platform` | string enum | yes | `android` for E1 | +| `candidates` | array | yes | 1..25 items per request (client batches) | +| `candidates[].packageName` | string | yes | `^[\w.-]{1,255}$` | +| `candidates[].appLabel` | string | yes | utf-8, 1..200 chars | +| `candidates[].signingFingerprint` | string\|null | optional | `^[0-9A-F]{2}(:[0-9A-F]{2}){31}$` SHA-256 hex | +| `candidates[].installerKind` | string enum\|null | optional | `obtainium`, `fdroid`, `play`, `aurora`, `galaxy`, `oem_other`, `browser`, `sideload`, `system`, `github_store_self`, `unknown` | +| `candidates[].manifestHint.owner` | string\|null | optional | `^[\w.-]{1,39}$` | +| `candidates[].manifestHint.repo` | string\|null | optional | `^[\w.-]{1,100}$` | + +**Response (200):** + +```json +{ + "matches": [ + { + "packageName": "com.example.foo", + "candidates": [ + { + "owner": "octocat", + "repo": "hello-world", + "confidence": 0.78, + "source": "search", + "stars": 1240, + "description": "Example application" + } + ] + } + ] +} +``` + +`source` enum: `manifest` | `search` | `fingerprint`. `candidates[]` sorted by confidence descending, capped at 5. `stars` and `description` may be null. + +**Other status codes:** +- `400` — invalid body +- `429` — rate limited; include `Retry-After` (client schedules WorkManager retry) +- `503` — partial outage (client falls back to manifest-only) + +**Server-side scoring (per plan §3.2):** +- If `manifestHint.owner` and `repo` present → validate via HEAD against GitHub → `manifest` match at confidence 1.0 +- If `signingFingerprint` present → look up in `signing_fingerprint → (owner, repo)` table → `fingerprint` match at confidence 0.92 +- Else → score top 5 search results: exact-name match +0.4, substring +0.2, owner login matches packageName author segment +0.2, star bucket +0.05/0.10/0.15, has APK assets in last 5 releases +0.10 (else **−0.20** — heavy penalty for no-APK repos), description contains "Android"/"APK" +0.05 +- Cap search-only confidence at 0.85 (keeps out of auto-link tier) + +**Cache:** 24h server-side keyed on `(packageName, appLabel)`. Excludes `signingFingerprint` from the cache key so a returning user with a different fingerprint gets a fresh look-up. + +**Rate limit:** 60 req/hour/IP. Include `Retry-After` on 429. + +**DTO:** `core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchRequest.kt` and `ExternalMatchResponse.kt` already match this shape. + +--- + +### 2. `GET /v1/signing-seeds` — required + +Paginated incremental dump of signing-cert → GitHub-repo mappings, seeded from F-Droid index. Anonymous. + +**Request:** + +```http +GET /v1/signing-seeds?since=1714521600000&platform=android&cursor=opaque-cursor-string +``` + +| Param | Type | Required | Notes | +| --- | --- | --- | --- | +| `since` | integer (epoch millis) | optional | Only return rows observed at or after this timestamp | +| `cursor` | string | optional | Opaque pagination token from prior response | +| `platform` | string enum | required | `android` for E1 | + +**Response (200):** + +```json +{ + "rows": [ + { + "fingerprint": "AB:CD:EF:...", + "owner": "octocat", + "repo": "hello-world", + "observedAt": 1714521600000 + } + ], + "nextCursor": "opaque-string-or-null" +} +``` + +**Important:** `observedAt` MUST be **epoch milliseconds** (not seconds). The client stores this and passes it as `since` on the next sync. Mixing units silently corrupts the incremental cursor — there's a unit-tagged comment on the client DTO calling this out. + +**Page size:** 1000 rows recommended. Initial seed: 5–15k rows total (5–15 page calls). Daily delta: typically <200 rows. + +**Source:** F-Droid index has the `(certificate, source-code-URL)` mapping for ~5k OSS apps. Backend extracts it into the seed table on a daily cron. + +**DTO:** `core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt` + +--- + +### 3. Flag flip (no client release needed) + +After both endpoints are in production, flip `tweaks.externalMatchSearchEnabled` to `true`. The client picks this up via the existing tweaks DataStore channel — no client release required. + +If your tweaks infrastructure doesn't yet support remote-driven values for that flag, ship the default-on flip in the next client release. + +--- + +## Optional (defer if needed) + +- **Fingerprint-derived match in `POST /v1/external-match`** — frontend computes this locally from the seed table, so the backend's `source: "fingerprint"` path is a redundant safety net. Skip if it's complex. +- **Dynamic seed updates with full diff** — initial seed is enough for v1. Daily delta can land later. + +--- + +## Cannot defer + +- **`POST /v1/external-match`** — without it, Strategy 2 (search) is mocked and the medium-confidence tier produces no matches. Manifest hints + signing-cert seed still work, so the wizard is usable but coverage is narrower. + +--- + +## Telemetry overlap with E6 (clarification needed) + +E6's handoff document (`feature/e6-telemetry`) §3.4 "Import (E1 / E2)" says to wire `IMPORT_SCAN_STARTED`, `IMPORT_SCAN_COMPLETED`, `IMPORT_MATCH_ATTEMPTED`, `IMPORT_AUTO_LINKED`, `IMPORT_MANUALLY_LINKED`, `IMPORT_SKIPPED` from `LibraryImportViewModel.kt` via the new `ProductTelemetry` interface. + +**Two issues:** + +1. The class is named `ExternalImportViewModel.kt`, not `LibraryImportViewModel.kt`. Heads-up so the next person doesn't grep for the wrong name. +2. **E1 already fires those six events** via the existing `TelemetryRepository.import*` methods. The wiring is in: + - `ExternalImportRepositoryImpl.runFullScan / runDeltaScan` (importScanStarted / importScanCompleted / importMatchAttempted / importAutoLinked) + - `ExternalImportViewModel.skipPackage / pickSuggestion / submitSearchOverride` (importSkipped / importManuallyLinked / importSearchOverrideUsed / importSearchOverrideNoResults / importPermissionRequested / importPermissionOutcome) + - `DetailsViewModel.confirmUnlinkExternalApp` (importUnlinkedFromDetails) + +**Decision needed before E6 wires §3.4:** does `ProductTelemetry` replace `TelemetryRepository` for these events, or do they coexist? If "replace," E6's port is the right approach and the existing `TelemetryRepository.import*` calls get deleted. If "coexist," every import action fires *two* events on the wire — almost certainly wrong. + +Please respond with which path you intend so the E6 work doesn't double-emit. + +--- + +## Endpoint URLs + +The client base URL is the existing `BACKEND_BASE_URL`. Both new endpoints are siblings of `events`, `categories`, `topics`, `repo`, `releases`, `readme`, `user`. No new auth/scope needed. + +## Verification path + +```sh +# 1. Build the client APK against staging +./gradlew :composeApp:assembleDebug + +# 2. Flip the flag locally for testing +adb shell am start-foreground-service \ + -a zed.rainxch.tweak.SET \ + --es key external_match_search_enabled --ez value true + +# 3. Open the wizard, observe match calls hit your endpoint +adb logcat -s OkHttp | grep external-match + +# 4. Confirm match results render with `source: "search"` chip in the wizard UI +``` From dca895b0ae299ed6058c756c8c161a3aa3c052af Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 23:20:45 +0500 Subject: [PATCH 37/46] E1: permission rationale lists optional backend payload fields --- .../src/commonMain/composeResources/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index bc5535fcb..53bc38579 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -805,7 +805,7 @@ OK Find your GitHub apps - We can scan your installed apps and match them to GitHub releases — so updates and detection just work.\n\nTo do that, we need to see which apps you have. Without permission we can only see about 5 apps; with it, we can see all of them.\n\nWe never send the list of your apps anywhere without your permission. The match runs on your device. The optional backend lookup sends only the package name and app label of apps you ask us to match — never a full list of what\'s installed. + We can scan your installed apps and match them to GitHub releases — so updates and detection just work.\n\nTo do that, we need to see which apps you have. Without permission we can only see about 5 apps; with it, we can see all of them.\n\nWe never send the list of your apps anywhere without your permission. The match runs on your device. The optional backend lookup sends only the package name and app label, and when available the signing fingerprint, install source, and any GitHub-repo hint declared in the app\'s manifest — never a full list of what\'s installed. Continue Not now From 8def6cc4f746bccb4c0b172ac193b71778fda898 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 23:20:45 +0500 Subject: [PATCH 38/46] E1: track installed-app pre-state for bulk undo, short-circuit failed skip, exhaustive installer label --- .../import/ExternalImportViewModel.kt | 64 +++++++++++++++++-- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index c87f90cac..1461ba4f9 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -62,6 +62,11 @@ class ExternalImportViewModel( // auto-linked packages without round-tripping resolveMatches() (which // would issue another network call and could return different matches). private var lastResolvedMatches: List = emptyList() + // Mirror of autoLinkedPackages: per-package pre-link snapshot of whether an + // installed_apps row already existed. Bulk undo consults this to avoid + // wiping rows that pre-existed (e.g., the user had previously linked the + // app through some other path before auto-link added an entry to it). + private var autoLinkedHadInstalledRow: Map = emptyMap() private var hasStarted = false private var scanJob: Job? = null private var searchJob: Job? = null @@ -395,13 +400,27 @@ class ExternalImportViewModel( }.getOrNull() val hadInstalledRow = installedAppsRepository.getAppByPackage(packageName) != null - try { + // Short-circuit on failure: don't remove the card, don't offer undo, + // don't fire telemetry — the DAO state is unchanged. Surface an error + // so the user knows the action didn't take effect. + val ok = try { externalImportRepository.skipPackage(packageName, neverAsk = neverAsk) + true } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Skip failed for $packageName: ${e.message}") + false } + if (!ok) { + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_failed), + ), + ) + return@launch + } + runCatching { telemetry.importSkipped( countBucket = "1-2", @@ -552,6 +571,9 @@ class ExternalImportViewModel( private fun autoSummaryContinue() { val current = _state.value if (current.phase != ImportPhase.AutoImportSummary) return + // User accepted the auto-imports; the pre-link presence map is no + // longer needed and shouldn't leak into a subsequent wizard run. + autoLinkedHadInstalledRow = emptyMap() if (current.cards.isNotEmpty()) { _state.update { it.copy(phase = ImportPhase.AwaitingReview) } } else { @@ -574,16 +596,24 @@ class ExternalImportViewModel( return } + // Snapshot the pre-link map locally so a concurrent reset can't race us. + val hadInstalledMap = autoLinkedHadInstalledRow + viewModelScope.launch { // Roll each auto-linked package back to PENDING_REVIEW. Snapshot → // restoreDecision matches the per-row undo path, so the audit trail // and DAO state mirror what the user would see after a fresh scan - // with no auto-link applied. + // with no auto-link applied. installed_apps is only deleted for + // packages whose row did NOT pre-exist before auto-link — same + // policy as undoLast (PendingUndo.Kind.Link && !hadInstalledAppRowBefore). packages.forEach { pkg -> val snapshot = runCatching { externalImportRepository.snapshotDecision(pkg) }.getOrNull() - runCatching { installedAppsRepository.deleteInstalledApp(pkg) } + val hadRowBefore = hadInstalledMap[pkg] == true + if (!hadRowBefore) { + runCatching { installedAppsRepository.deleteInstalledApp(pkg) } + } if (snapshot != null) { runCatching { externalImportRepository.restoreDecision(snapshot) } } else { @@ -595,6 +625,7 @@ class ExternalImportViewModel( // the auto-link wave wholesale, so a stale "Undo" snackbar from a // pre-summary action would now point at a row we just restored. pendingUndo = null + autoLinkedHadInstalledRow = emptyMap() val matchesByPkg = lastResolvedMatches.associateBy { it.packageName } val restoredCards = packages.mapNotNull { pkg -> @@ -699,11 +730,19 @@ class ExternalImportViewModel( private suspend fun autoMaterialize(matches: List): List { val linked = mutableListOf() + val hadInstalledRow = mutableMapOf() matches.forEach { result -> val top = result.topSuggestion ?: return@forEach if (top.confidence < AUTO_LINK_THRESHOLD) return@forEach val candidate = candidatesByPackage[result.packageName] ?: return@forEach + // Capture pre-link installed_apps presence BEFORE materializeAndMark + // writes the row. autoSummaryUndoAll reads this to decide whether to + // delete the row on undo or leave it (because it pre-existed). + val pre = runCatching { + installedAppsRepository.getAppByPackage(result.packageName) != null + }.getOrDefault(false) + val ok = materializeAndMark( candidate = candidate, @@ -711,8 +750,12 @@ class ExternalImportViewModel( repo = top.repo, source = "auto-${top.source.name.lowercase()}", ) - if (ok) linked += result.packageName + if (ok) { + linked += result.packageName + hadInstalledRow[result.packageName] = pre + } } + autoLinkedHadInstalledRow = hadInstalledRow.toMap() return linked } @@ -828,14 +871,23 @@ class ExternalImportViewModel( ) private suspend fun InstallerKind.toUiLabel(): String = + // Exhaustive: STORE_PLAY / STORE_AURORA / STORE_GALAXY / STORE_OEM_OTHER / + // SYSTEM are filtered out at the scanner today, but the wizard's enum + // contract is shared with the scanner — handle every value explicitly so + // a future scanner change can't silently mislabel a candidate. when (this) { InstallerKind.STORE_OBTAINIUM -> getString(Res.string.external_import_installer_obtainium) InstallerKind.STORE_FDROID -> getString(Res.string.external_import_installer_fdroid) InstallerKind.BROWSER -> getString(Res.string.external_import_installer_browser) InstallerKind.SIDELOAD -> getString(Res.string.external_import_installer_sideload) InstallerKind.GITHUB_STORE_SELF -> getString(Res.string.external_import_installer_self) - InstallerKind.UNKNOWN -> getString(Res.string.external_import_installer_unknown) - else -> getString(Res.string.external_import_installer_unknown) + InstallerKind.UNKNOWN, + InstallerKind.STORE_PLAY, + InstallerKind.STORE_AURORA, + InstallerKind.STORE_GALAXY, + InstallerKind.STORE_OEM_OTHER, + InstallerKind.SYSTEM, + -> getString(Res.string.external_import_installer_unknown) } private data class PendingUndo( From a9e700065e0ac1f97fe034b5b28f351406ea72b2 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 23:20:45 +0500 Subject: [PATCH 39/46] core/data: backstop delta scan fires for first-time installs of untracked packages --- .../data/services/PackageEventReceiver.kt | 90 ++++++++++--------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index 4e087f7cb..10f927079 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -110,53 +110,59 @@ class PackageEventReceiver() : try { val repo = getRepository() val monitor = getMonitor() - val app = repo.getAppByPackage(packageName) ?: return + val app = repo.getAppByPackage(packageName) - if (app.isPendingInstall) { - val systemInfo = monitor.getInstalledPackageInfo(packageName) - if (systemInfo != null) { - val expectedVersionCode = app.latestVersionCode ?: 0L - val wasActuallyUpdated = - expectedVersionCode > 0L && - systemInfo.versionCode >= expectedVersionCode + // First-time installs (app == null) skip the tracked-app branches + // but MUST still hit the backstop delta-scan launch below — that's + // how a freshly-installed GitHub app surfaces as a wizard candidate + // when the user installs it after the initial scan. + if (app != null) { + if (app.isPendingInstall) { + val systemInfo = monitor.getInstalledPackageInfo(packageName) + if (systemInfo != null) { + val expectedVersionCode = app.latestVersionCode ?: 0L + val wasActuallyUpdated = + expectedVersionCode > 0L && + systemInfo.versionCode >= expectedVersionCode - if (wasActuallyUpdated) { - repo.updateAppVersion( - packageName = packageName, - newTag = app.latestVersion ?: systemInfo.versionName, - newAssetName = app.latestAssetName ?: "", - newAssetUrl = app.latestAssetUrl ?: "", - newVersionName = systemInfo.versionName, - newVersionCode = systemInfo.versionCode, - signingFingerprint = app.signingFingerprint, - ) - repo.updatePendingStatus(packageName, false) - Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" } - } else { - repo.updateApp( - app.copy( - isPendingInstall = false, - installedVersionName = systemInfo.versionName, - installedVersionCode = systemInfo.versionCode, - isUpdateAvailable = - ( - app.latestVersionCode - ?: 0L - ) > systemInfo.versionCode, - ), - ) - Logger.i { - "Package replaced but not updated to target: $packageName " + - "(system: v${systemInfo.versionName}/${systemInfo.versionCode}, " + - "target: v${app.latestVersionName}/${app.latestVersionCode})" + if (wasActuallyUpdated) { + repo.updateAppVersion( + packageName = packageName, + newTag = app.latestVersion ?: systemInfo.versionName, + newAssetName = app.latestAssetName ?: "", + newAssetUrl = app.latestAssetUrl ?: "", + newVersionName = systemInfo.versionName, + newVersionCode = systemInfo.versionCode, + signingFingerprint = app.signingFingerprint, + ) + repo.updatePendingStatus(packageName, false) + Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" } + } else { + repo.updateApp( + app.copy( + isPendingInstall = false, + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + isUpdateAvailable = + ( + app.latestVersionCode + ?: 0L + ) > systemInfo.versionCode, + ), + ) + Logger.i { + "Package replaced but not updated to target: $packageName " + + "(system: v${systemInfo.versionName}/${systemInfo.versionCode}, " + + "target: v${app.latestVersionName}/${app.latestVersionCode})" + } } + } else { + repo.updatePendingStatus(packageName, false) + Logger.i { "Resolved pending install via broadcast (no system info): $packageName" } } } else { - repo.updatePendingStatus(packageName, false) - Logger.i { "Resolved pending install via broadcast (no system info): $packageName" } + handleExternalInstall(packageName, app, repo, monitor) } - } else { - handleExternalInstall(packageName, app, repo, monitor) } } catch (e: Exception) { Logger.e { "PackageEventReceiver error for $packageName: ${e.message}" } @@ -166,7 +172,7 @@ class PackageEventReceiver() : // import banner can pick up the new candidate. Guarded so we // don't churn on apps the user already linked or asked us to // ignore. Runs on the app scope — independent of the install - // path above. + // path above. Always fires regardless of whether `app` was found. getBackstopScope().launch { runCatching { val rescan = shouldRescan(packageName) From 843ebf2895d2912dcb52da2f8aa3e254ef3b7be0 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 23:20:45 +0500 Subject: [PATCH 40/46] E1 handoff: clarify cache key includes fingerprint, document actual 429 client behavior --- roadmap/E1_BACKEND_HANDOFF.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/roadmap/E1_BACKEND_HANDOFF.md b/roadmap/E1_BACKEND_HANDOFF.md index 2950b4704..4f51ed706 100644 --- a/roadmap/E1_BACKEND_HANDOFF.md +++ b/roadmap/E1_BACKEND_HANDOFF.md @@ -67,7 +67,7 @@ Content-Type: application/json **Other status codes:** - `400` — invalid body -- `429` — rate limited; include `Retry-After` (client schedules WorkManager retry) +- `429` — rate limited; include `Retry-After` (in seconds). **Current client behavior:** `BackendApiClient.postExternalMatch` throws `RateLimitedException` on 429 and `ExternalImportRepositoryImpl.resolveMatches` logs the failure per-batch and continues with the remaining batches; no automatic WorkManager-backed retry is scheduled today. The plan called for WorkManager retry on `Retry-After` but it isn't wired yet — a backend that hard-rate-limits aggressively will see partial-result wizard sessions until that retry path is implemented in `resolveMatches`. - `503` — partial outage (client falls back to manifest-only) **Server-side scoring (per plan §3.2):** @@ -76,7 +76,7 @@ Content-Type: application/json - Else → score top 5 search results: exact-name match +0.4, substring +0.2, owner login matches packageName author segment +0.2, star bucket +0.05/0.10/0.15, has APK assets in last 5 releases +0.10 (else **−0.20** — heavy penalty for no-APK repos), description contains "Android"/"APK" +0.05 - Cap search-only confidence at 0.85 (keeps out of auto-link tier) -**Cache:** 24h server-side keyed on `(packageName, appLabel)`. Excludes `signingFingerprint` from the cache key so a returning user with a different fingerprint gets a fresh look-up. +**Cache:** 24h server-side keyed on `(packageName, appLabel, signingFingerprint)`. Including `signingFingerprint` in the key means a returning user with a different fingerprint (e.g., reinstalled the app from a different source after a key rotation) bypasses the cache and gets a fresh look-up. If `signingFingerprint` is null, treat the null itself as part of the key — don't merge null-fingerprint hits with the same package's known-fingerprint hits. (Original plan §3.2 wording was inverted — please use this clarified version.) **Rate limit:** 60 req/hour/IP. Include `Retry-After` on 429. From f0004b766f19df36895e4d72527e892dec8a8fbf Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 05:49:56 +0500 Subject: [PATCH 41/46] core/data: retry unlink on transient failure, use typed ExternalLinkState in shouldRescan --- .../data/services/PackageEventReceiver.kt | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index 10f927079..4af786501 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -14,6 +14,7 @@ import org.koin.core.component.inject import zed.rainxch.core.data.local.db.dao.ExternalLinkDao import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.system.ExternalLinkState import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.util.VersionVerdict import zed.rainxch.core.domain.util.resolveExternalInstallVerdict @@ -197,10 +198,9 @@ class PackageEventReceiver() : .getOrNull() if (tracked != null) return false val link = runCatching { getExternalLinkDao().get(packageName) }.getOrNull() - return when (link?.state) { - "MATCHED", "NEVER_ASK" -> false - else -> true - } + val state = link?.state ?: return true + return state != ExternalLinkState.MATCHED.name && + state != ExternalLinkState.NEVER_ASK.name } /** @@ -308,7 +308,28 @@ class PackageEventReceiver() : try { getRepository().deleteInstalledApp(packageName) runCatching { getExternalImport().unlink(packageName) } - .onFailure { Logger.w(it) { "External link cleanup failed for $packageName" } } + .onFailure { initialError -> + Logger.w(initialError) { "External link cleanup failed for $packageName; scheduling retry" } + // A failed unlink leaves a stale MATCHED/NEVER_ASK row that + // makes `shouldRescan` return false on a future reinstall — + // i.e., the user reinstalls a previously-tracked app and we + // silently fail to re-link it. Retry once on the app scope + // after a short backoff. If the retry also fails, the next + // periodic worker sweep gets a chance via `runPeriodicExternalDeltaScan`. + getBackstopScope().launch { + kotlinx.coroutines.delay(UNLINK_RETRY_DELAY_MS) + runCatching { getExternalImport().unlink(packageName) } + .onSuccess { + Logger.i { "External link cleanup retry succeeded for $packageName" } + } + .onFailure { retryError -> + Logger.w(retryError) { + "External link cleanup final failure for $packageName; " + + "row may persist until next periodic scan" + } + } + } + } Logger.i { "Removed uninstalled app via broadcast: $packageName" } } catch (e: Exception) { Logger.e { "PackageEventReceiver remove error for $packageName: ${e.message}" } @@ -316,6 +337,8 @@ class PackageEventReceiver() : } companion object { + private const val UNLINK_RETRY_DELAY_MS: Long = 1_000 + fun createIntentFilter(): IntentFilter = IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_ADDED) From 1d7358ea82d5ebc8fdd7415321a02acf5ce5d1ee Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 05:49:56 +0500 Subject: [PATCH 42/46] E1: capture pre-link snapshots for true bulk-undo, track per-package success in skip-remaining --- .../import/ExternalImportViewModel.kt | 91 +++++++++++++------ 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 1461ba4f9..e500efb74 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -67,6 +67,11 @@ class ExternalImportViewModel( // wiping rows that pre-existed (e.g., the user had previously linked the // app through some other path before auto-link added an entry to it). private var autoLinkedHadInstalledRow: Map = emptyMap() + // Per-package external_links snapshot captured BEFORE auto-link writes the + // MATCHED row. Bulk undo restores from these so the DAO actually rolls back + // to the pre-link state (typically PENDING_REVIEW). Snapshotting AFTER the + // link would just re-apply the linked state — a silent no-op. + private var autoLinkedPreSnapshots: Map = emptyMap() private var hasStarted = false private var scanJob: Job? = null private var searchJob: Job? = null @@ -571,9 +576,10 @@ class ExternalImportViewModel( private fun autoSummaryContinue() { val current = _state.value if (current.phase != ImportPhase.AutoImportSummary) return - // User accepted the auto-imports; the pre-link presence map is no + // User accepted the auto-imports; the pre-link metadata is no // longer needed and shouldn't leak into a subsequent wizard run. autoLinkedHadInstalledRow = emptyMap() + autoLinkedPreSnapshots = emptyMap() if (current.cards.isNotEmpty()) { _state.update { it.copy(phase = ImportPhase.AwaitingReview) } } else { @@ -596,27 +602,27 @@ class ExternalImportViewModel( return } - // Snapshot the pre-link map locally so a concurrent reset can't race us. + // Snapshot the pre-link maps locally so a concurrent reset can't race us. val hadInstalledMap = autoLinkedHadInstalledRow + val preSnapshots = autoLinkedPreSnapshots viewModelScope.launch { - // Roll each auto-linked package back to PENDING_REVIEW. Snapshot → - // restoreDecision matches the per-row undo path, so the audit trail - // and DAO state mirror what the user would see after a fresh scan - // with no auto-link applied. installed_apps is only deleted for - // packages whose row did NOT pre-exist before auto-link — same - // policy as undoLast (PendingUndo.Kind.Link && !hadInstalledAppRowBefore). + // Roll each auto-linked package back to its PRE-LINK external_links + // state using the snapshot captured BEFORE materializeAndMark wrote + // the MATCHED row. Snapshotting now would just read the post-link + // MATCHED state and restoreDecision would be a no-op. installed_apps + // is only deleted for packages whose row did NOT pre-exist before + // auto-link — same policy as undoLast. packages.forEach { pkg -> - val snapshot = runCatching { - externalImportRepository.snapshotDecision(pkg) - }.getOrNull() + val preSnapshot = preSnapshots[pkg] val hadRowBefore = hadInstalledMap[pkg] == true if (!hadRowBefore) { runCatching { installedAppsRepository.deleteInstalledApp(pkg) } } - if (snapshot != null) { - runCatching { externalImportRepository.restoreDecision(snapshot) } + if (preSnapshot != null) { + runCatching { externalImportRepository.restoreDecision(preSnapshot) } } else { + // No pre-link row existed — drop the auto-link row entirely. runCatching { externalImportRepository.unlink(pkg) } } } @@ -626,6 +632,7 @@ class ExternalImportViewModel( // pre-summary action would now point at a row we just restored. pendingUndo = null autoLinkedHadInstalledRow = emptyMap() + autoLinkedPreSnapshots = emptyMap() val matchesByPkg = lastResolvedMatches.associateBy { it.packageName } val restoredCards = packages.mapNotNull { pkg -> @@ -688,21 +695,30 @@ class ExternalImportViewModel( if (remaining.isEmpty()) return viewModelScope.launch { + // Track per-package outcome so a partial failure doesn't claim the + // entire wizard cleared and doesn't fire confetti / Done telemetry + // on packages whose DAO state is unchanged. + val successes = mutableSetOf() + val failures = mutableListOf() remaining.forEach { card -> try { externalImportRepository.skipPackage(card.packageName, neverAsk = false) + successes += card.packageName } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.error("Skip-remaining failed for ${card.packageName}: ${e.message}") + failures += card.packageName } } - runCatching { - telemetry.importSkipped( - countBucket = bucketCount(remaining.size), - persisted = "7day", - ) + if (successes.isNotEmpty()) { + runCatching { + telemetry.importSkipped( + countBucket = bucketCount(successes.size), + persisted = "7day", + ) + } } // Skip-remaining is intentionally not undoable — bulk skip clears @@ -710,35 +726,52 @@ class ExternalImportViewModel( // snackbar isn't a sensible affordance for "undo seven things". pendingUndo = null - _state.update { - it.copy( - cards = persistentListOf(), + val allSucceeded = failures.isEmpty() + _state.update { state -> + val keptCards = state.cards.filter { it.packageName !in successes }.toImmutableList() + state.copy( + cards = keptCards, expandedPackages = persistentSetOf(), activeSearchPackage = null, searchQuery = "", searchResults = persistentListOf(), isSearching = false, searchError = null, - phase = ImportPhase.Done, - skipped = it.skipped + remaining.size, - showCompletionToast = true, + phase = if (allSucceeded && keptCards.isEmpty()) ImportPhase.Done else state.phase, + skipped = state.skipped + successes.size, + showCompletionToast = allSucceeded && keptCards.isEmpty(), + ) + } + + if (allSucceeded) { + _events.send(ExternalImportEvent.PlayConfetti) + } else { + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_failed), + ), ) } - _events.send(ExternalImportEvent.PlayConfetti) } } private suspend fun autoMaterialize(matches: List): List { val linked = mutableListOf() val hadInstalledRow = mutableMapOf() + val preSnapshots = mutableMapOf() matches.forEach { result -> val top = result.topSuggestion ?: return@forEach if (top.confidence < AUTO_LINK_THRESHOLD) return@forEach val candidate = candidatesByPackage[result.packageName] ?: return@forEach - // Capture pre-link installed_apps presence BEFORE materializeAndMark - // writes the row. autoSummaryUndoAll reads this to decide whether to - // delete the row on undo or leave it (because it pre-existed). + // Capture pre-link state BEFORE materializeAndMark writes the + // MATCHED row. Bulk undo uses both: the pre-link snapshot to + // restore the DAO row to its original state, and the + // installed_apps presence flag to decide whether to delete the + // installed_apps row (only if auto-link created it). + val preSnapshot = runCatching { + externalImportRepository.snapshotDecision(result.packageName) + }.getOrNull() val pre = runCatching { installedAppsRepository.getAppByPackage(result.packageName) != null }.getOrDefault(false) @@ -753,9 +786,11 @@ class ExternalImportViewModel( if (ok) { linked += result.packageName hadInstalledRow[result.packageName] = pre + preSnapshots[result.packageName] = preSnapshot } } autoLinkedHadInstalledRow = hadInstalledRow.toMap() + autoLinkedPreSnapshots = preSnapshots.toMap() return linked } From c91046408dd8b2fcdcce85572949ab0ac9248000 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 05:49:56 +0500 Subject: [PATCH 43/46] E1 handoff: 503 fallback covers manifest hints AND signing-cert seed --- roadmap/E1_BACKEND_HANDOFF.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/E1_BACKEND_HANDOFF.md b/roadmap/E1_BACKEND_HANDOFF.md index 4f51ed706..87932de7b 100644 --- a/roadmap/E1_BACKEND_HANDOFF.md +++ b/roadmap/E1_BACKEND_HANDOFF.md @@ -68,7 +68,7 @@ Content-Type: application/json **Other status codes:** - `400` — invalid body - `429` — rate limited; include `Retry-After` (in seconds). **Current client behavior:** `BackendApiClient.postExternalMatch` throws `RateLimitedException` on 429 and `ExternalImportRepositoryImpl.resolveMatches` logs the failure per-batch and continues with the remaining batches; no automatic WorkManager-backed retry is scheduled today. The plan called for WorkManager retry on `Retry-After` but it isn't wired yet — a backend that hard-rate-limits aggressively will see partial-result wizard sessions until that retry path is implemented in `resolveMatches`. -- `503` — partial outage (client falls back to manifest-only) +- `503` — partial outage. **Current client behavior:** `ExternalImportRepositoryImpl.resolveMatches` runs three strategies in parallel — manifest hints (parsed locally from each candidate's `AndroidManifest.xml`), signing-cert seed (looked up locally from the cached `signing_fingerprints` table), and the backend match call. When `BackendApiClient.postExternalMatch` fails with 503, only the backend strategy drops out for that batch; manifest-derived suggestions and signing-cert hits still flow through unaffected. So the client degrades to "local-only matching" — *not* manifest-only — as long as the seed sync has run at least once. Newly-installed apps with no manifest hint and no fingerprint match will see no suggestions until the backend recovers. **Server-side scoring (per plan §3.2):** - If `manifestHint.owner` and `repo` present → validate via HEAD against GitHub → `manifest` match at confidence 1.0 From 8dbe575788b0cf08cde07eb86a37241730d705c3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:12:12 +0500 Subject: [PATCH 44/46] core/data: post-retry delta scan recovers reinstalls during the unlink backoff window --- .../core/data/services/PackageEventReceiver.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index 4af786501..75bbf523e 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -321,6 +321,19 @@ class PackageEventReceiver() : runCatching { getExternalImport().unlink(packageName) } .onSuccess { Logger.i { "External link cleanup retry succeeded for $packageName" } + // Recovery delta scan: a fast reinstall during the + // retry backoff window would have hit shouldRescan + // while the row was still MATCHED → no rescan + // queued. Now that the row is gone, evaluate + // shouldRescan again and fire if the package is + // currently installed. + runCatching { + if (shouldRescan(packageName)) { + getExternalImport().runDeltaScan(setOf(packageName)) + } + }.onFailure { e -> + Logger.w(e) { "Post-retry delta scan failed for $packageName" } + } } .onFailure { retryError -> Logger.w(retryError) { From d247b53ce31074983d501a36eb59fdeb76d13a93 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:12:12 +0500 Subject: [PATCH 45/46] E1: fail-fast on undo and snapshot-capture failures (preserves undo metadata, surfaces error) --- .../import/ExternalImportViewModel.kt | 122 ++++++++++++------ 1 file changed, 83 insertions(+), 39 deletions(-) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index e500efb74..377327f4b 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -400,9 +400,22 @@ class ExternalImportViewModel( private fun skipPackage(packageName: String, neverAsk: Boolean) { val card = _state.value.cards.firstOrNull { it.packageName == packageName } ?: return viewModelScope.launch { - val snapshot = runCatching { + // Distinguish "no prior row" (success → null) from "couldn't read" + // (failure). Treating the latter as null would let undoLast's + // fallback unlink wipe a row that should have been preserved. + val snapshotResult = runCatching { externalImportRepository.snapshotDecision(packageName) - }.getOrNull() + } + if (snapshotResult.isFailure) { + logger.error("Snapshot read failed for $packageName: ${snapshotResult.exceptionOrNull()?.message}") + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_failed), + ), + ) + return@launch + } + val snapshot = snapshotResult.getOrNull() val hadInstalledRow = installedAppsRepository.getAppByPackage(packageName) != null // Short-circuit on failure: don't remove the card, don't offer undo, @@ -465,9 +478,19 @@ class ExternalImportViewModel( return@launch } - val snapshot = runCatching { + val snapshotResult = runCatching { externalImportRepository.snapshotDecision(packageName) - }.getOrNull() + } + if (snapshotResult.isFailure) { + logger.error("Snapshot read failed for $packageName: ${snapshotResult.exceptionOrNull()?.message}") + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_failed), + ), + ) + return@launch + } + val snapshot = snapshotResult.getOrNull() val hadInstalledRow = installedAppsRepository.getAppByPackage(packageName) != null val materialized = materializeAndMark(candidate, suggestion.owner, suggestion.repo, source) @@ -513,32 +536,24 @@ class ExternalImportViewModel( private fun undoLast() { val undo = pendingUndo ?: return - pendingUndo = null viewModelScope.launch { try { + // Run rollback DAO ops without swallowing — any failure must + // abort and preserve `pendingUndo` so the user can retry from + // the snackbar. UI state is mutated only after every op succeeds. if (undo.kind == PendingUndo.Kind.Link && !undo.hadInstalledAppRowBefore) { - // The link materialized a new installed_apps row; remove it - // before restoring the link table state so getAppByPackage - // observers see the rollback in one shot. - runCatching { - installedAppsRepository.deleteInstalledApp(undo.packageName) - } + installedAppsRepository.deleteInstalledApp(undo.packageName) } if (undo.snapshot != null) { externalImportRepository.restoreDecision(undo.snapshot) } else { - // No prior row existed (first-ever scan + decision). Drop - // the new link entirely so the candidate becomes pending - // again on the next scan. - runCatching { externalImportRepository.unlink(undo.packageName) } + externalImportRepository.unlink(undo.packageName) } - // Re-insert the original card at the top so the user can retry - // immediately. We use the card we cached on the snackbar token — - // re-running resolveMatches would issue a network call and risks - // returning different suggestions than the user just saw. + // All DAO ops succeeded — now mutate UI and consume the token. + pendingUndo = null _state.update { current -> if (current.cards.any { it.packageName == undo.packageName }) { current @@ -564,6 +579,7 @@ class ExternalImportViewModel( throw e } catch (e: Exception) { logger.error("Undo failed for ${undo.packageName}: ${e.message}") + // Preserve pendingUndo so the snackbar can offer Undo again. _events.send( ExternalImportEvent.ShowError( getString(Res.string.external_import_undo_failed), @@ -609,27 +625,42 @@ class ExternalImportViewModel( viewModelScope.launch { // Roll each auto-linked package back to its PRE-LINK external_links // state using the snapshot captured BEFORE materializeAndMark wrote - // the MATCHED row. Snapshotting now would just read the post-link - // MATCHED state and restoreDecision would be a no-op. installed_apps - // is only deleted for packages whose row did NOT pre-exist before - // auto-link — same policy as undoLast. - packages.forEach { pkg -> - val preSnapshot = preSnapshots[pkg] - val hadRowBefore = hadInstalledMap[pkg] == true - if (!hadRowBefore) { - runCatching { installedAppsRepository.deleteInstalledApp(pkg) } - } - if (preSnapshot != null) { - runCatching { externalImportRepository.restoreDecision(preSnapshot) } - } else { - // No pre-link row existed — drop the auto-link row entirely. - runCatching { externalImportRepository.unlink(pkg) } + // the MATCHED row. installed_apps is only deleted for packages whose + // row did NOT pre-exist before auto-link — same policy as undoLast. + // + // Fail-fast: any DAO failure aborts the bulk undo before we touch + // UI state or clear the pre-link maps. The user keeps seeing the + // AutoImportSummary screen and can retry. Already-rolled-back + // packages stay rolled back (idempotent on retry). + try { + packages.forEach { pkg -> + val preSnapshot = preSnapshots[pkg] + val hadRowBefore = hadInstalledMap[pkg] == true + if (!hadRowBefore) { + installedAppsRepository.deleteInstalledApp(pkg) + } + if (preSnapshot != null) { + externalImportRepository.restoreDecision(preSnapshot) + } else { + // No pre-link row existed — drop the auto-link row entirely. + externalImportRepository.unlink(pkg) + } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Bulk undo failed: ${e.message}") + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_undo_failed), + ), + ) + return@launch } - // Bulk-undo invalidates the single-row undo token: the user cleared - // the auto-link wave wholesale, so a stale "Undo" snackbar from a - // pre-summary action would now point at a row we just restored. + // All rollbacks succeeded — invalidate the single-row undo token, + // clear the per-package metadata so a subsequent wizard run can't + // see stale pre-link snapshots, and rebuild the wizard. pendingUndo = null autoLinkedHadInstalledRow = emptyMap() autoLinkedPreSnapshots = emptyMap() @@ -769,9 +800,22 @@ class ExternalImportViewModel( // restore the DAO row to its original state, and the // installed_apps presence flag to decide whether to delete the // installed_apps row (only if auto-link created it). - val preSnapshot = runCatching { + // + // Snapshot failure must skip the auto-link entirely — without a + // reliable pre-link snapshot, undo would fall back to unlink and + // wipe a row that should have been preserved. Push the candidate + // through manual review instead. + val snapshotResult = runCatching { externalImportRepository.snapshotDecision(result.packageName) - }.getOrNull() + } + if (snapshotResult.isFailure) { + logger.warn( + "Skip auto-link for ${result.packageName}: " + + "pre-link snapshot read failed (${snapshotResult.exceptionOrNull()?.message})", + ) + return@forEach + } + val preSnapshot = snapshotResult.getOrNull() val pre = runCatching { installedAppsRepository.getAppByPackage(result.packageName) != null }.getOrDefault(false) From e6dceca6ca6640a39e802dd5add76db477c763b3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:12:13 +0500 Subject: [PATCH 46/46] E1 handoff: manifestHint nullability semantics, confidence clamping contract --- roadmap/E1_BACKEND_HANDOFF.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/roadmap/E1_BACKEND_HANDOFF.md b/roadmap/E1_BACKEND_HANDOFF.md index 87932de7b..311c39924 100644 --- a/roadmap/E1_BACKEND_HANDOFF.md +++ b/roadmap/E1_BACKEND_HANDOFF.md @@ -41,6 +41,8 @@ Content-Type: application/json | `candidates[].manifestHint.owner` | string\|null | optional | `^[\w.-]{1,39}$` | | `candidates[].manifestHint.repo` | string\|null | optional | `^[\w.-]{1,100}$` | +**`manifestHint` semantics.** The entire `candidates[].manifestHint` object MAY be omitted when the client found no in-APK hint; today's client always sends it as `{ "owner": null, "repo": null }` for symmetry, but backends should treat omission and a present-but-fully-null object identically (no hint provided). Validation runs on each non-null field independently — e.g., if `manifestHint.owner` is null and `manifestHint.repo` is non-null, only `manifestHint.repo` is regex-validated against `^[\w.-]{1,100}$`. A present `manifestHint` with both `owner` AND `repo` non-null is the only shape that should drive the manifest-hint scoring branch; partial values (one null, one not) should be treated as no hint and fall through to the search/fingerprint paths. + **Response (200):** ```json @@ -76,6 +78,8 @@ Content-Type: application/json - Else → score top 5 search results: exact-name match +0.4, substring +0.2, owner login matches packageName author segment +0.2, star bucket +0.05/0.10/0.15, has APK assets in last 5 releases +0.10 (else **−0.20** — heavy penalty for no-APK repos), description contains "Android"/"APK" +0.05 - Cap search-only confidence at 0.85 (keeps out of auto-link tier) +**Confidence clamping.** Backend MUST clamp every emitted `confidence` to `[0.0, 1.0]` before serialising the response. The −0.20 no-APK penalty plus other negative signals can produce a negative pre-clamp score for very weak matches; clamp those to 0.0 rather than emitting negative values. Rationale: client tier logic uses ≥0.85 (auto-link), 0.5..0.85 (preselected wizard suggestion), <0.5 (wizard with no preselect), and `RepoCandidateRow` displays `confidence * 100` percent rounded to int — client also runs a defensive `coerceIn(0, 100)` on the percentage but treating that as the source of truth on the wire would let invalid backend payloads slip through analytics. Manifest (1.0) and fingerprint (0.92) paths are already fixed values and don't require clamping; only the search-scoring path needs it. + **Cache:** 24h server-side keyed on `(packageName, appLabel, signingFingerprint)`. Including `signingFingerprint` in the key means a returning user with a different fingerprint (e.g., reinstalled the app from a different source after a key rotation) bypasses the cache and gets a fresh look-up. If `signingFingerprint` is null, treat the null itself as part of the key — don't merge null-fingerprint hits with the same package's known-fingerprint hits. (Original plan §3.2 wording was inverted — please use this clarified version.) **Rate limit:** 60 req/hour/IP. Include `Retry-After` on 429.