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/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 75735631a..09d159ecb 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -18,6 +18,21 @@ + + + + + + + + + + ().scheduleInitialScanIfNeeded() + }.onFailure { + Logger.w(it) { "Initial external scan scheduling failed" } + } + } + } + + private fun scheduleSigningSeedSync() { + appScope.launch { + runCatching { + get().syncSigningFingerprintSeed() + }.onFailure { + Logger.w(it) { "Signing seed sync failed" } + } + } } private fun startDownloadNotificationObserver() { @@ -85,6 +109,9 @@ class GithubStoreApp : Application() { PackageEventReceiver( installedAppsRepository = get(), packageMonitor = get(), + externalImportRepository = get(), + externalLinkDao = get(), + appScope = get(), ) val filter = PackageEventReceiver.createIntentFilter() 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..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 @@ -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 @@ -48,6 +50,7 @@ val viewModelsModule = attestationVerifier = get(), downloadOrchestrator = get(), telemetryRepository = get(), + externalImportRepository = get(), ) } viewModelOf(::DeveloperProfileViewModel) 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 8c94b0463..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 @@ -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 @@ -28,6 +29,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 @@ -43,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, @@ -296,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() @@ -309,10 +326,35 @@ fun AppNavigation( ), ) }, + onNavigateToExternalImport = { + navController.navigate(GithubStoreGraph.ExternalImportScreen) + }, viewModel = appsViewModel, state = appsState, ) } + + composable { + ExternalImportRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + isComingFromUpdate = true, + ), + ) + }, + onAddManually = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY, true) + navController.navigateUp() + }, + ) + } } val currentScreen = @@ -331,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 diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt index 917a7323a..0cfbb6c5a 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt @@ -46,4 +46,7 @@ sealed interface GithubStoreGraph { @Serializable data object SponsorScreen : GithubStoreGraph + + @Serializable + data object ExternalImportScreen : GithubStoreGraph } 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/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/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/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index bd21a1de4..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 @@ -11,7 +11,10 @@ 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.ExternalLinkState import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.util.VersionVerdict import zed.rainxch.core.domain.util.resolveExternalInstallVerdict @@ -31,10 +34,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 +54,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?, @@ -91,57 +111,96 @@ 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}" } } + + // 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. Always fires regardless of whether `app` was found. + getBackstopScope().launch { + runCatching { + val rescan = shouldRescan(packageName) + if (rescan) { + 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() + val state = link?.state ?: return true + return state != ExternalLinkState.MATCHED.name && + state != ExternalLinkState.NEVER_ASK.name } /** @@ -248,6 +307,42 @@ class PackageEventReceiver() : private suspend fun onPackageRemoved(packageName: String) { try { getRepository().deleteInstalledApp(packageName) + runCatching { getExternalImport().unlink(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" } + // 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) { + "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}" } @@ -255,6 +350,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) 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..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 @@ -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,37 @@ 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 +218,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/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..0bf314e7b --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt @@ -0,0 +1,141 @@ +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 + + // 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 + + val installer = installerPackageNameFor(packageName) + return mapInstaller(installer, isSystem = false) + } + + 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", + "dev.imranr.obtainium.fdroid", + ) + 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", + ) + + // 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.", + ) + } +} 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..07cee9b15 --- /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 ?: 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() + } + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString(":") { "%02X".format(it) } +} 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..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 @@ -16,20 +16,27 @@ 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 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 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 +54,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 +195,31 @@ val coreModule = ) } + single { BackendExternalMatchApi(get()) } + + single { MockExternalMatchApi() } + + single { + ExternalMatchApiSelector( + real = get(), + mock = get(), + tweaks = get(), + scope = get(), + ) + } + + single { + ExternalImportRepositoryImpl( + scanner = get(), + externalLinkDao = get(), + signingFingerprintDao = get(), + preferences = get(), + externalMatchApi = get(), + backendClient = get(), + telemetry = get(), + ) + } + // Application-scoped download / install orchestrator. Lives // for the process lifetime so downloads survive screen // navigation. ViewModels are observers, never owners. @@ -292,4 +326,12 @@ val databaseModule = single { get().searchHistoryDao } + + single { + get().externalLinkDao + } + + single { + get().signingFingerprintDao + } } 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/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..7232fb602 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt @@ -0,0 +1,21 @@ +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, + // 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/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..740b699ca --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/ExternalLinkDao.kt @@ -0,0 +1,41 @@ +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) + + @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/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, +) 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..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 @@ -33,8 +33,11 @@ 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.SigningFingerprintSeedResponse import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.domain.model.ProxyConfig import kotlin.coroutines.cancellation.CancellationException @@ -241,6 +244,43 @@ 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 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/network/ExternalMatchApi.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt new file mode 100644 index 000000000..72f354ec9 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt @@ -0,0 +1,68 @@ +package zed.rainxch.core.data.network + +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 + +interface ExternalMatchApi { + // NOTE: chunking lives in the repo; impls receive whatever the repo passes + 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, + 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 = + 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 new file mode 100644 index 000000000..54d208354 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -0,0 +1,598 @@ +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 kotlin.coroutines.cancellation.CancellationException +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.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.ExternalDecisionSnapshot +import zed.rainxch.core.domain.system.ExternalLinkState +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 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 + // 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 firstLaunch = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] == null + runCatching { + if (firstLaunch) { + runCatching { telemetry.importScanStarted(trigger = "first_launch") } + .onFailure { Logger.d { "telemetry importScanStarted failed: ${it.message}" } } + } + runFullScan() + }.onSuccess { + if (firstLaunch) markInitialScanComplete() + }.onFailure { + Logger.w(it) { "External scan failed; will retry on next launch." } + } + } + + override suspend fun runFullScan(): ScanResult { + val started = nowMillis() + val granted = scanner.isPermissionGranted() + val rawCandidates = scanner.snapshot() + val candidates = rawCandidates.filter { hasPositiveEvidence(it) } + 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++ + if (existing != null && updated.state != ExternalLinkState.PENDING_REVIEW.name) preservedDecisions++ + externalLinkDao.upsert(updated) + } + + 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( + candidateCountBucket = bucketCandidateCount(candidates.size), + durationMsBucket = bucketDurationMs(durationMs), + ) + }.onFailure { Logger.d { "telemetry importScanCompleted failed: ${it.message}" } } + + return ScanResult( + totalCandidates = candidates.size, + newCandidates = newCandidates, + autoLinked = 0, + pendingReview = pendingReview, + durationMillis = durationMs, + 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 rawCandidate = scanner.snapshotSingle(pkg) + 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 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 { + 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 = + 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 { 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 -> + val suggestions = mutableListOf() + candidate.manifestHint?.let { hint -> + suggestions += RepoMatchSuggestion( + owner = hint.owner, + repo = hint.repo, + confidence = hint.confidence, + source = RepoMatchSource.MANIFEST, + ) + } + fingerprintHits[candidate.packageName]?.let { suggestions += it } + backendResults[candidate.packageName]?.let { suggestions += it } + 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) + } + } + + override suspend fun linkManually( + packageName: String, + owner: String, + repo: String, + source: String, + ): Result { + val now = nowMillis() + 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 } + } + + 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 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, + ) + } + + 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) } + 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() { + 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() { + 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 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, + now: Long, + ): ExternalLinkEntity { + 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, + ) + } + + 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 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 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" + + // 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, + ) + } +} 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/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 4d5aff5cf..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 @@ -230,6 +231,39 @@ class TweaksRepositoryImpl( } } + override fun getExternalImportEnabled(): Flow = + 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 + } + } + + 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 @@ -251,5 +285,8 @@ 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") + private val EXTERNAL_IMPORT_BANNER_DISMISSED_AT_KEY = intPreferencesKey("external_import_banner_dismissed_at") } } 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/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..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 @@ -3,10 +3,19 @@ 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 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 .databaseBuilder( name = dbFile.absolutePath, 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 +} 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..6f595029a --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt @@ -0,0 +1,49 @@ +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.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 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 snapshotDecision(packageName: String): ExternalDecisionSnapshot? + + suspend fun restoreDecision(snapshot: ExternalDecisionSnapshot) + + suspend fun rescanSinglePackage(packageName: String): RepoMatchResult? + + suspend fun searchRepos(query: String): Result> + + suspend fun syncSigningFingerprintSeed() + + suspend fun pruneExpiredSkips() + + suspend fun isPermissionGranted(): Boolean +} 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..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 @@ -23,6 +23,36 @@ 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) + + suspend fun importManuallyLinked(countBucket: String, source: String) + + suspend fun importSkipped(countBucket: String, persisted: String) + + suspend fun importUnlinkedFromDetails() + + suspend fun importPermissionRequested() + + suspend fun importPermissionOutcome(granted: Boolean, sdkIntBucket: String) + + suspend fun importSearchOverrideUsed() + + suspend fun importSearchOverrideNoResults() + + suspend fun signingSeedSyncCompleted(rowsAddedBucket: String, durationMsBucket: String) + + suspend fun externalMatchApiFailure(statusCodeBucket: String, retried: Boolean) + suspend fun flushPending() /** 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..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 @@ -86,4 +86,16 @@ 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) + + fun getExternalImportBannerDismissedAtCount(): Flow + + suspend fun setExternalImportBannerDismissedAtCount(count: Int) } 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/ExternalDecisionSnapshot.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt new file mode 100644 index 000000000..4369a0697 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt @@ -0,0 +1,11 @@ +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?, +) 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..c02703a1f --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt @@ -0,0 +1,10 @@ +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, +) diff --git a/core/presentation/.DS_Store b/core/presentation/.DS_Store new file mode 100644 index 000000000..6726bd65f Binary files /dev/null and b/core/presentation/.DS_Store differ diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index dc535c5b8..53bc38579 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -768,4 +768,97 @@ 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 + Installed via %1$s + We think this is %1$s · %2$d%% + Tap to find a repo + Expand to see other matches + Collapse card + 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, 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 + + 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 + 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 + + + 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 + Scan for GitHub apps + + + 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/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..1bd88316c --- /dev/null +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt @@ -0,0 +1,44 @@ +package zed.rainxch.apps.presentation.import.util + +import android.content.Context +import android.os.Build +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 + +@Composable +actual fun rememberPackageVisibilityRequester(): PackageVisibilityRequester { + val context = LocalContext.current.applicationContext + return remember(context) { AndroidPackageVisibilityRequester(context) } +} + +private class AndroidPackageVisibilityRequester( + private val context: Context, +) : PackageVisibilityRequester { + // 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() } + visible.size >= GRANT_THRESHOLD + } + + override suspend fun requestOrOpenSettings(): Boolean { + // 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/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/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/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 1cc9645d7..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 @@ -100,4 +100,14 @@ sealed interface AppsAction { data class OnInstallPendingApp( val app: InstalledAppUi, ) : AppsAction + + // External import banner (E1) + 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/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..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 @@ -48,7 +49,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 @@ -91,6 +92,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 @@ -121,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 @@ -147,6 +150,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 +180,10 @@ fun AppsRoot( is AppsEvent.ImportComplete -> { // handled by ShowSuccess } + + AppsEvent.NavigateToExternalImport -> { + onNavigateToExternalImport() + } } } @@ -294,24 +302,36 @@ 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) + }, + ) } } }, ) }, 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( @@ -520,6 +540,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 }, 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..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 @@ -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 @@ -32,6 +33,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,8 +60,10 @@ class AppsViewModel( private val shareManager: ShareManager, private val tweaksRepository: TweaksRepository, private val downloadOrchestrator: DownloadOrchestrator, + private val externalImportRepository: ExternalImportRepository, ) : ViewModel() { companion object { + private const val BANNER_THRESHOLD = 1 private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L } @@ -68,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 @@ -78,6 +90,7 @@ class AppsViewModel( if (!hasLoadedInitialData) { loadApps() observeLiquidGlassEnabled() + observePendingExternalImports() hasLoadedInitialData = true } }.stateIn( @@ -98,6 +111,29 @@ class AppsViewModel( } } + private fun observePendingExternalImports() { + viewModelScope.launch { + externalImportRepository.pendingCandidateCountFlow() + .combine(tweaksRepository.getExternalImportBannerDismissedAtCount()) { count, dismissedAt -> + count to dismissedAt + } + .collect { (count, dismissedAt) -> + // 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, + showImportProposalBanner = shouldShow && !it.isExternalImportInFlight, + ) + } + } + } + } + private val _events = Channel() val events = _events.receiveAsFlow() @@ -441,6 +477,41 @@ class AppsViewModel( AppsAction.OnDismissUninstallDialog -> { _state.update { it.copy(appPendingUninstall = null) } } + + AppsAction.OnImportProposalReview -> { + 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) } + _events.send(AppsEvent.NavigateToExternalImport) + } + } + + 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) + } + } } } 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..b08d5ead7 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt @@ -0,0 +1,47 @@ +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 class OnPermissionGranted(val sdkInt: Int?) : ExternalImportAction + + data class OnPermissionDenied(val sdkInt: Int?) : ExternalImportAction + + data class OnSkipCard(val packageName: String) : ExternalImportAction + + data class OnSkipForever(val packageName: String) : ExternalImportAction + + data object OnSkipRemaining : ExternalImportAction + + data class OnPickSuggestion( + val packageName: String, + val suggestion: RepoSuggestionUi, + ) : ExternalImportAction + + data class OnLinkCard(val packageName: String) : ExternalImportAction + + data class OnToggleCardExpanded(val packageName: String) : ExternalImportAction + + data class OnSearchOverrideChanged( + val packageName: String, + val query: String, + ) : ExternalImportAction + + data class OnSearchOverrideSubmit(val packageName: String) : ExternalImportAction + + data object OnUndoLast : 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 new file mode 100644 index 000000000..ddcf44463 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportEvent.kt @@ -0,0 +1,15 @@ +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 + + 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 new file mode 100644 index 000000000..a44a9650f --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -0,0 +1,270 @@ +@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.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 +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 +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.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 +import zed.rainxch.apps.presentation.import.components.ImportProgressScreen +import zed.rainxch.apps.presentation.import.components.PermissionRationaleScreen +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 +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 +import zed.rainxch.githubstore.core.presentation.res.external_import_undo_action + +@Composable +fun ExternalImportRoot( + onNavigateBack: () -> Unit, + onNavigateToDetails: (repoId: Long) -> Unit, + onAddManually: () -> Unit, + viewModel: ExternalImportViewModel = koinViewModel(), +) { + 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) { + ExternalImportEvent.NavigateBack -> onNavigateBack() + is ExternalImportEvent.NavigateToDetails -> onNavigateToDetails(event.repoId) + ExternalImportEvent.NavigateBackAndOpenManualLink -> onAddManually() + is ExternalImportEvent.ShowError -> { + 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) + } + } + } + } + } + + LaunchedEffect(Unit) { + if (state.phase == ImportPhase.Idle) { + viewModel.onAction(ExternalImportAction.OnStart) + } + } + + val reducedMotion = rememberSystemReducedMotion() + + CompositionLocalProvider(LocalReducedMotion provides reducedMotion) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.external_import_top_bar_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = { viewModel.onAction(ExternalImportAction.OnExit) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.external_import_top_bar_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, + contentDescription = stringResource(Res.string.external_import_overflow_more), + ) + } + DropdownMenu( + expanded = menuOpen, + onDismissRequest = { menuOpen = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.external_import_overflow_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, + ) + } + + ImportPhase.RequestingPermission -> { + PermissionRationaleScreen( + onAction = viewModel::onAction, + ) + } + + 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( + isPermissionDenied = state.isPermissionDenied, + onRequestPermission = { + viewModel.onAction(ExternalImportAction.OnRequestPermission) + }, + onExit = { viewModel.onAction(ExternalImportAction.OnExit) }, + onAddManually = { + viewModel.onAction(ExternalImportAction.OnAddManually) + }, + ) + } else { + WizardList( + cards = state.cards, + expandedPackages = state.expandedPackages, + activeSearchPackage = state.activeSearchPackage, + searchQuery = state.searchQuery, + searchResults = state.searchResults, + isSearching = state.isSearching, + searchError = state.searchError, + onToggleExpanded = { pkg -> + viewModel.onAction( + ExternalImportAction.OnToggleCardExpanded(pkg), + ) + }, + onPick = { pkg, suggestion -> + viewModel.onAction( + ExternalImportAction.OnPickSuggestion(pkg, suggestion), + ) + }, + onSkip = { pkg -> + viewModel.onAction(ExternalImportAction.OnSkipCard(pkg)) + }, + onLink = { pkg -> + viewModel.onAction(ExternalImportAction.OnLinkCard(pkg)) + }, + onSearchQueryChange = { pkg, query -> + viewModel.onAction( + ExternalImportAction.OnSearchOverrideChanged(pkg, query), + ) + }, + onSearchSubmit = { pkg -> + viewModel.onAction( + ExternalImportAction.OnSearchOverrideSubmit(pkg), + ) + }, + onAddManually = { + viewModel.onAction(ExternalImportAction.OnAddManually) + }, + ) + } + } + + 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) }, + onAddManually = { + viewModel.onAction(ExternalImportAction.OnAddManually) + }, + ) + } 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/ExternalImportState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt new file mode 100644 index 000000000..65553561b --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt @@ -0,0 +1,37 @@ +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 + +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 autoLinkedPackages: ImmutableList = persistentListOf(), + val autoLinkedLabels: 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, + val visiblePackageCount: Int = 0, + val invisiblePackageCountEstimate: Int = 0, + val showCompletionToast: Boolean = false, + val errorMessage: String? = null, +) { + val cardsRemaining: Int + get() = cards.size + + val isWizardComplete: Boolean + 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 new file mode 100644 index 000000000..377327f4b --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -0,0 +1,1014 @@ +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.toPersistentList +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +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 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 +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.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 +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 +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() { + 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() + // 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() + // 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 + private var pendingUndo: PendingUndo? = null + + 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) } + viewModelScope.launch { runCatching { telemetry.importPermissionRequested() } } + } + + is ExternalImportAction.OnPermissionGranted -> { + _state.update { it.copy(isPermissionDenied = false) } + emitPermissionOutcome(granted = true, sdkInt = action.sdkInt) + startScanIfIdle(force = true) + } + + is ExternalImportAction.OnPermissionDenied -> { + _state.update { it.copy(isPermissionDenied = true) } + emitPermissionOutcome(granted = false, sdkInt = action.sdkInt) + startScanIfIdle(force = true) + } + + is ExternalImportAction.OnSkipCard -> skipPackage(action.packageName, neverAsk = false) + + is ExternalImportAction.OnSkipForever -> skipPackage(action.packageName, neverAsk = true) + + ExternalImportAction.OnSkipRemaining -> skipRemaining() + + is ExternalImportAction.OnPickSuggestion -> + pickSuggestion(action.packageName, action.suggestion) + + is ExternalImportAction.OnLinkCard -> linkCardWithPreselected(action.packageName) + + is ExternalImportAction.OnToggleCardExpanded -> toggleCardExpanded(action.packageName) + + is ExternalImportAction.OnSearchOverrideChanged -> { + // 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) + } + } + } + + is ExternalImportAction.OnSearchOverrideSubmit -> submitSearchOverride(action.packageName) + + ExternalImportAction.OnUndoLast -> undoLast() + + ExternalImportAction.OnExit -> { + viewModelScope.launch { + _events.send(ExternalImportEvent.NavigateBack) + } + } + + ExternalImportAction.OnDismissCompletionToast -> { + _state.update { it.copy(showCompletionToast = false) } + } + + ExternalImportAction.OnAutoSummaryContinue -> autoSummaryContinue() + + ExternalImportAction.OnAutoSummaryUndoAll -> autoSummaryUndoAll() + + ExternalImportAction.OnAddManually -> { + viewModelScope.launch { + _events.send(ExternalImportEvent.NavigateBackAndOpenManualLink) + } + } + } + } + + 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 + scanJob = viewModelScope.launch { + try { + _state.update { it.copy(phase = ImportPhase.Scanning, errorMessage = null) } + + externalImportRepository.runFullScan() + + val candidates = externalImportRepository.pendingCandidatesFlow().first() + candidatesByPackage = candidates.associateBy { it.packageName } + + _state.update { + it.copy( + phase = ImportPhase.AutoImporting, + totalCandidates = candidates.size, + ) + } + + val matches = externalImportRepository.resolveMatches(candidates) + lastResolvedMatches = matches + val autoLinked = autoMaterialize(matches) + val autoLinkedPackages = autoLinked.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 (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 = 0, + showCompletionToast = true, + ) + } + _events.send(ExternalImportEvent.PlayConfetti) + } else { + _state.update { + it.copy( + phase = ImportPhase.AwaitingReview, + cards = cards, + autoImported = 0, + ) + } + } + } 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 ?: getString(Res.string.external_import_error_scan_failed_default), + ), + ) + } + } + } + + private suspend 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 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, + 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() } } + searchJob = viewModelScope.launch { + val result = runCatching { externalImportRepository.searchRepos(query) } + .getOrElse { e -> + if (e is CancellationException) throw e + Result.failure(e) + } + + result.fold( + onSuccess = { suggestions -> + if (suggestions.isEmpty()) { + runCatching { telemetry.importSearchOverrideNoResults() } + } + _state.update { + // 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, + searchResults = + suggestions.map { s -> s.toUi() }.toImmutableList(), + ) + } + }, + 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 { + if (it.activeSearchPackage != packageName) it + else it.copy( + isSearching = false, + searchError = e.message ?: fallback, + searchResults = persistentListOf(), + ) + } + }, + ) + } + } + + private fun skipPackage(packageName: String, neverAsk: Boolean) { + val card = _state.value.cards.firstOrNull { it.packageName == packageName } ?: return + viewModelScope.launch { + // 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) + } + 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, + // 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", + persisted = if (neverAsk) "forever" else "7day", + ) + } + 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(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[packageName] + + viewModelScope.launch { + if (candidate == null) { + logger.error("Cannot materialize $packageName: candidate missing from snapshot") + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_failed), + ), + ) + return@launch + } + + val snapshotResult = runCatching { + externalImportRepository.snapshotDecision(packageName) + } + 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) + if (!materialized) { + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_error_link_network), + ), + ) + return@launch + } + runCatching { + telemetry.importManuallyLinked(countBucket = "1-2", source = source) + } + 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 + + 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) { + installedAppsRepository.deleteInstalledApp(undo.packageName) + } + + if (undo.snapshot != null) { + externalImportRepository.restoreDecision(undo.snapshot) + } else { + externalImportRepository.unlink(undo.packageName) + } + + // 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 + } 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}") + // Preserve pendingUndo so the snackbar can offer Undo again. + _events.send( + ExternalImportEvent.ShowError( + getString(Res.string.external_import_undo_failed), + ), + ) + } + } + } + + private fun autoSummaryContinue() { + val current = _state.value + if (current.phase != ImportPhase.AutoImportSummary) return + // 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 { + _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 + } + + // 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 its PRE-LINK external_links + // state using the snapshot captured BEFORE materializeAndMark wrote + // 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 + } + + // 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() + + 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 { + telemetry.importPermissionOutcome( + granted = granted, + 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 + 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 + } + } + + if (successes.isNotEmpty()) { + runCatching { + telemetry.importSkipped( + countBucket = bucketCount(successes.size), + persisted = "7day", + ) + } + } + + // 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 + + 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 = 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), + ), + ) + } + } + } + + 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 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). + // + // 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) + } + 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) + + val ok = + materializeAndMark( + candidate = candidate, + owner = top.owner, + repo = top.repo, + source = "auto-${top.source.name.lowercase()}", + ) + if (ok) { + linked += result.packageName + hadInstalledRow[result.packageName] = pre + preSnapshots[result.packageName] = preSnapshot + } + } + autoLinkedHadInstalledRow = hadInstalledRow.toMap() + autoLinkedPreSnapshots = preSnapshots.toMap() + 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 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 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 + } + } + if (_state.value.cards.isEmpty()) { + _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.MANUAL + }, + stars = stars, + description = description, + ) + + 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, + 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( + 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/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/CandidateCard.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt new file mode 100644 index 000000000..853e7af28 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt @@ -0,0 +1,284 @@ +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 +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.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +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.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.semantics.Role +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 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 +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( + candidate: CandidateUi, + expanded: Boolean, + searchQuery: String, + searchResults: ImmutableList, + isSearching: Boolean, + searchError: String?, + 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), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = + modifier + .fillMaxWidth() + .clickable( + onClickLabel = if (expanded) collapseLabel else expandLabel, + role = Role.Button, + ) { onToggleExpanded() }, + ) { + Column( + modifier = Modifier.padding(20.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) { + 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, + ) + + 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, + ) + } + } + } + } + } + } +} + +@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( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + InstalledAppIcon( + packageName = candidate.packageName, + appName = candidate.appLabel, + modifier = + Modifier + .size(48.dp) + .clip(RoundedCornerShape(14.dp)), + ) + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = candidate.appLabel, + style = MaterialTheme.typography.titleMedium, + 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( + 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), + ) + } +} + +@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 = containerColor, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (suggestion == null) { + Text( + text = stringResource(Res.string.external_import_card_preselect_unknown), + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + ) + } else { + val percent = (suggestion.confidence * 100).roundToInt().coerceIn(0, 100) + Text( + 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 new file mode 100644 index 000000000..c9c19a521 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.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.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.Button +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 +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( + autoImported: Int, + manuallyLinked: Int, + skipped: Int, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + 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( + text = + pluralStringResource( + Res.plurals.external_import_completion_headline, + tracked, + tracked, + ), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + if (skipped > 0) { + Text( + text = stringResource(Res.string.external_import_completion_skipped_subline, skipped), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + Button(onClick = onExit) { + 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/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/EmptyStateScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt new file mode 100644 index 000000000..1697fa674 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.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.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.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.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 +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( + isPermissionDenied: Boolean, + onRequestPermission: () -> Unit, + onExit: () -> Unit, + onAddManually: () -> 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.onSurfaceVariant, + modifier = Modifier.size(64.dp), + ) + Text( + 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) { + 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, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(72.dp), + ) + Text( + 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( + 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) { + Text(stringResource(Res.string.external_import_empty_ok)) + } + Button(onClick = onRequestPermission) { + 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/ImportProgressScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt new file mode 100644 index 000000000..e9c94e13f --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt @@ -0,0 +1,79 @@ +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.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 +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( + phase: ImportPhase, + totalCandidates: Int, + modifier: Modifier = Modifier, +) { + val headline = + when (phase) { + 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( + 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, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + + Text( + 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 new file mode 100644 index 000000000..4fe82b649 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt @@ -0,0 +1,96 @@ +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 +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( + pendingCount: Int, + onReview: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.FileDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp), + ) + + Spacer(Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = + pluralStringResource( + Res.plurals.external_import_proposal_banner_headline, + pendingCount, + pendingCount, + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = stringResource(Res.string.external_import_proposal_banner_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + + Spacer(Modifier.width(8.dp)) + + TextButton(onClick = onReview) { + Text( + text = stringResource(Res.string.external_import_proposal_banner_review), + ) + } + + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + 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 new file mode 100644 index 000000000..05006b63e --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt @@ -0,0 +1,102 @@ +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.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 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 +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( + onAction: (ExternalImportAction) -> Unit, + modifier: Modifier = Modifier, +) { + val sdkInt = rememberSdkInt() + val requester = rememberPackageVisibilityRequester() + val scope = rememberCoroutineScope() + + 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( + text = stringResource(Res.string.external_import_permission_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(Res.string.external_import_permission_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { onAction(ExternalImportAction.OnPermissionDenied(sdkInt)) }, + ) { + Text(stringResource(Res.string.external_import_permission_not_now)) + } + Button(onClick = { + scope.launch { + onAction(ExternalImportAction.OnRequestPermission) + // 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 { + ExternalImportAction.OnPermissionDenied(sdkInt) + } + onAction(action) + } + }) { + 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 new file mode 100644 index 000000000..a4b69c0d6 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt @@ -0,0 +1,127 @@ +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.heightIn +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.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 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( + suggestion: RepoSuggestionUi, + onPick: (RepoSuggestionUi) -> Unit, + modifier: Modifier = Modifier, +) { + 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.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + suggestion.confidence >= 0.5 -> + MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer + else -> + MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .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)) + + val confidenceLabel = + stringResource(Res.string.external_import_match_confidence_a11y, percent) + Surface( + color = chipBg, + shape = RoundedCornerShape(12.dp), + modifier = + Modifier.semantics { + contentDescription = confidenceLabel + }, + ) { + Text( + 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), + ) + } + } +} + +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..a9dbd06ef --- /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 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_url + +@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, + isError = !searchError.isNullOrBlank(), + supportingText = { + if (!searchError.isNullOrBlank()) { + Text(text = searchError) + } + }, + placeholder = { + Text( + text = stringResource(Res.string.external_import_search_placeholder_url), + ) + }, + trailingIcon = { + IconButton(onClick = onSubmit) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(Res.string.external_import_search_icon_label), + ) + } + }, + 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 (results.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + results.forEach { suggestion -> + RepoCandidateRow( + suggestion = suggestion, + onPick = onPick, + ) + } + } + } else if (query.isNotBlank() && !isSearching) { + Text( + 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/WizardList.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt new file mode 100644 index 000000000..3ceae3d12 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt @@ -0,0 +1,127 @@ +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 +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 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 +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, + onAddManually: () -> 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) }, + ) + } + + 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), + ) + } +} + +@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), + ) + } +} 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..6a7cdf677 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/ImportPhase.kt @@ -0,0 +1,11 @@ +package zed.rainxch.apps.presentation.import.model + +enum class ImportPhase { + Idle, + RequestingPermission, + Scanning, + AutoImporting, + AutoImportSummary, + 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..dc7cde52f --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt @@ -0,0 +1,19 @@ +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, + MANUAL, +} 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..822b21bf8 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt @@ -0,0 +1,12 @@ +package zed.rainxch.apps.presentation.import.util + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberPackageVisibilityRequester(): PackageVisibilityRequester + +interface PackageVisibilityRequester { + suspend fun isGranted(): Boolean + + suspend fun requestOrOpenSettings(): Boolean +} 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/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..d997981e3 --- /dev/null +++ b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt @@ -0,0 +1,14 @@ +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 suspend fun isGranted(): Boolean = true + + override suspend fun requestOrOpenSettings(): Boolean = true +} 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 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 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..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 @@ -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 @@ -82,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 @@ -288,6 +300,44 @@ fun DetailsRoot( ) } + if (state.showUnlinkConfirmation) { + val appName = state.installedApp?.appName ?: "" + AlertDialog( + onDismissRequest = { + viewModel.onAction(DetailsAction.OnDismissUnlinkConfirmation) + }, + title = { + Text(text = stringResource(Res.string.details_unlink_external_app_dialog_title)) + }, + text = { + Text( + text = stringResource(Res.string.details_unlink_external_app_dialog_body, appName), + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.onAction(DetailsAction.OnConfirmUnlinkExternalApp) + }, + ) { + Text( + text = stringResource(Res.string.details_unlink_external_app_dialog_confirm), + 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,49 @@ 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, + contentDescription = stringResource(Res.string.details_unlink_external_app_more_options), + ) + } + DropdownMenu( + expanded = menuOpen, + onDismissRequest = { menuOpen = false }, + ) { + DropdownMenuItem( + text = { + Text(text = stringResource(Res.string.details_unlink_external_app_menu)) + }, + 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..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 @@ -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 @@ -72,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 @@ -114,6 +117,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 +165,39 @@ 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) + } + runCatching { telemetryRepository.importUnlinkedFromDetails() } + _events.send( + 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}") + _events.send( + DetailsEvent.OnMessage( + getString(Res.string.details_unlink_external_app_failure), + ), + ) + } + } + } + @OptIn(ExperimentalTime::class) fun onAction(action: DetailsAction) { when (action) { @@ -209,6 +246,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( diff --git a/roadmap/E1_BACKEND_HANDOFF.md b/roadmap/E1_BACKEND_HANDOFF.md new file mode 100644 index 000000000..311c39924 --- /dev/null +++ b/roadmap/E1_BACKEND_HANDOFF.md @@ -0,0 +1,191 @@ +# 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}$` | + +**`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 +{ + "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` (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. **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 +- 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) + +**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. + +**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 +```