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