Skip to content

Commit e9d7216

Browse files
committed
feat(details): Implement manual tracking for existing apps
This commit introduces the ability for users to manually track apps that are already installed on their system but not yet managed by the application. It also enhances the background synchronization logic to detect external version changes. - **feat(details)**: Added `TrackExistingApp` action and logic to `DetailsViewModel` to allow users to register an already installed app into the local database based on repository metadata. - **feat(details)**: Introduced `isTrackable` state to identify when a repository corresponds to an installed but untracked system package. - **feat(sync)**: Updated `SyncInstalledAppsUseCase` to detect external version changes (sideloads, downgrades, or root-level updates) by comparing database records with the system's package manager. - **refactor(sync)**: Improved sync reporting to include the count of version-checked apps.
1 parent a09c957 commit e9d7216

4 files changed

Lines changed: 161 additions & 1 deletion

File tree

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import zed.rainxch.core.domain.system.PackageMonitor
1717
* 2. Migrate legacy apps missing versionName/versionCode fields
1818
* 3. Resolve pending installs once they appear in the system package manager
1919
* 4. Clean up stale pending installs (older than 24 hours)
20+
* 5. Detect external version changes (downgrades on rooted devices, sideloads, etc.)
2021
*
2122
* This should be called before loading or refreshing app data to ensure consistency.
2223
*/
@@ -44,6 +45,7 @@ class SyncInstalledAppsUseCase(
4445
val toMigrate = mutableListOf<Pair<String, MigrationResult>>()
4546
val toResolvePending = mutableListOf<InstalledApp>()
4647
val toDeleteStalePending = mutableListOf<String>()
48+
val toSyncVersions = mutableListOf<InstalledApp>()
4749

4850
appsInDb.forEach { app ->
4951
val isOnSystem = installedPackageNames.contains(app.packageName)
@@ -64,6 +66,11 @@ class SyncInstalledAppsUseCase(
6466
val migrationResult = determineMigrationData(app)
6567
toMigrate.add(app.packageName to migrationResult)
6668
}
69+
70+
// Detect external version changes (downgrades on rooted devices, sideloads, etc.)
71+
isOnSystem && platform == Platform.ANDROID -> {
72+
toSyncVersions.add(app)
73+
}
6774
}
6875
}
6976

@@ -130,11 +137,42 @@ class SyncInstalledAppsUseCase(
130137
logger.error("Failed to migrate $packageName: ${e.message}")
131138
}
132139
}
140+
141+
toSyncVersions.forEach { app ->
142+
try {
143+
val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName)
144+
if (systemInfo != null && systemInfo.versionCode != app.installedVersionCode) {
145+
val wasDowngrade = systemInfo.versionCode < app.installedVersionCode
146+
val latestVersionCode = app.latestVersionCode ?: 0L
147+
val isUpdateAvailable = latestVersionCode > systemInfo.versionCode
148+
149+
installedAppsRepository.updateApp(
150+
app.copy(
151+
installedVersionName = systemInfo.versionName,
152+
installedVersionCode = systemInfo.versionCode,
153+
installedVersion = systemInfo.versionName,
154+
isUpdateAvailable = isUpdateAvailable
155+
)
156+
)
157+
158+
val action = if (wasDowngrade) "downgrade" else "external update"
159+
logger.info(
160+
"Detected $action for ${app.packageName}: " +
161+
"DB v${app.installedVersionName}(${app.installedVersionCode}) → " +
162+
"System v${systemInfo.versionName}(${systemInfo.versionCode}), " +
163+
"updateAvailable=$isUpdateAvailable"
164+
)
165+
}
166+
} catch (e: Exception) {
167+
logger.error("Failed to sync version for ${app.packageName}: ${e.message}")
168+
}
169+
}
133170
}
134171

135172
logger.info(
136173
"Sync completed: ${toDelete.size} deleted, ${toDeleteStalePending.size} stale pending removed, " +
137-
"${toResolvePending.size} pending resolved, ${toMigrate.size} migrated"
174+
"${toResolvePending.size} pending resolved, ${toMigrate.size} migrated, " +
175+
"${toSyncVersions.size} version-checked"
138176
)
139177

140178
Result.success(Unit)

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ sealed interface DetailsAction {
2323
data object OpenInAppManager : DetailsAction
2424
data object OnToggleInstallDropdown : DetailsAction
2525

26+
data object TrackExistingApp : DetailsAction
27+
2628
data object OnNavigateBackClick : DetailsAction
2729

2830
data object OnToggleFavorite : DetailsAction

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,18 @@ data class DetailsState(
5353
val installedApp: InstalledApp? = null,
5454
val isFavourite: Boolean = false,
5555
val isStarred: Boolean = false,
56+
val isTrackingApp: Boolean = false,
5657
) {
58+
/**
59+
* True when the app is detected as installed on the system (via assets matching)
60+
* but is NOT yet tracked in our database. Shows the "Track this app" button.
61+
*/
62+
val isTrackable: Boolean
63+
get() = installedApp == null &&
64+
!isLoading &&
65+
repository != null &&
66+
primaryAsset != null
67+
5768
val filteredReleases: List<GithubRelease>
5869
get() = when (selectedReleaseCategory) {
5970
ReleaseCategory.STABLE -> allReleases.filter { !it.isPrerelease }

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,111 @@ class DetailsViewModel(
302302
}
303303
}
304304

305+
@OptIn(ExperimentalTime::class)
306+
private fun trackExistingApp() {
307+
viewModelScope.launch {
308+
try {
309+
val repo = _state.value.repository ?: return@launch
310+
val release = _state.value.selectedRelease
311+
val primaryAsset = _state.value.primaryAsset
312+
313+
if (platform != Platform.ANDROID) return@launch
314+
315+
_state.update { it.copy(isTrackingApp = true) }
316+
317+
// Try to find the package name from the primary asset's APK info
318+
// We need to check if any app matching this repo is installed
319+
val allPackages = packageMonitor.getAllInstalledPackageNames()
320+
321+
// Try to extract package name from the APK asset name pattern
322+
// Common patterns: com.example.app, app-release.apk, etc.
323+
val possiblePackageName = "app.github.${repo.owner.login}.${repo.name}".lowercase()
324+
325+
// Check if already tracked
326+
val existingTracked = installedAppsRepository.getAppByRepoId(repo.id)
327+
if (existingTracked != null) {
328+
_events.send(DetailsEvent.OnMessage(getString(Res.string.already_tracked)))
329+
_state.update { it.copy(isTrackingApp = false) }
330+
return@launch
331+
}
332+
333+
val systemInfo = packageMonitor.getInstalledPackageInfo(possiblePackageName)
334+
335+
val packageName: String
336+
val versionName: String
337+
val versionCode: Long
338+
val appName: String
339+
340+
if (systemInfo != null && systemInfo.isInstalled) {
341+
packageName = possiblePackageName
342+
versionName = systemInfo.versionName
343+
versionCode = systemInfo.versionCode
344+
appName = repo.name
345+
} else {
346+
// Can't detect package on system — still track with release tag info
347+
packageName = possiblePackageName
348+
versionName = release?.tagName ?: "unknown"
349+
versionCode = 0L
350+
appName = repo.name
351+
}
352+
353+
val releaseTag = release?.tagName ?: versionName
354+
355+
val installedApp = InstalledApp(
356+
packageName = packageName,
357+
repoId = repo.id,
358+
repoName = repo.name,
359+
repoOwner = repo.owner.login,
360+
repoOwnerAvatarUrl = repo.owner.avatarUrl,
361+
repoDescription = repo.description,
362+
primaryLanguage = repo.language,
363+
repoUrl = repo.htmlUrl,
364+
installedVersion = releaseTag,
365+
installedAssetName = primaryAsset?.name,
366+
installedAssetUrl = primaryAsset?.downloadUrl,
367+
latestVersion = releaseTag,
368+
latestAssetName = primaryAsset?.name,
369+
latestAssetUrl = primaryAsset?.downloadUrl,
370+
latestAssetSize = primaryAsset?.size,
371+
appName = appName,
372+
installSource = InstallSource.MANUAL,
373+
installedAt = System.now().toEpochMilliseconds(),
374+
lastCheckedAt = System.now().toEpochMilliseconds(),
375+
lastUpdatedAt = System.now().toEpochMilliseconds(),
376+
isUpdateAvailable = false,
377+
updateCheckEnabled = true,
378+
releaseNotes = release?.description ?: "",
379+
systemArchitecture = installer.detectSystemArchitecture().name,
380+
fileExtension = primaryAsset?.name?.substringAfterLast('.', "apk") ?: "apk",
381+
isPendingInstall = false,
382+
installedVersionName = versionName,
383+
installedVersionCode = versionCode,
384+
latestVersionName = versionName,
385+
latestVersionCode = versionCode
386+
)
387+
388+
installedAppsRepository.saveInstalledApp(installedApp)
389+
390+
// Reload the installed app state
391+
val savedApp = installedAppsRepository.getAppByRepoId(repo.id)
392+
_state.update { it.copy(installedApp = savedApp, isTrackingApp = false) }
393+
394+
_events.send(DetailsEvent.OnMessage(getString(Res.string.app_tracked_successfully)))
395+
396+
logger.debug("Successfully tracked existing app: ${repo.name} as $packageName")
397+
398+
} catch (e: Exception) {
399+
logger.error("Failed to track existing app: ${e.message}")
400+
_state.update { it.copy(isTrackingApp = false) }
401+
_events.send(
402+
DetailsEvent.OnMessage(
403+
getString(Res.string.failed_to_track_app, e.message ?: "Unknown error")
404+
)
405+
)
406+
}
407+
}
408+
}
409+
305410
@OptIn(ExperimentalTime::class)
306411
fun onAction(action: DetailsAction) {
307412
when (action) {
@@ -655,6 +760,10 @@ class DetailsViewModel(
655760
}
656761
}
657762

763+
DetailsAction.TrackExistingApp -> {
764+
trackExistingApp()
765+
}
766+
658767
DetailsAction.OnNavigateBackClick -> {
659768
// Handled in composable
660769
}

0 commit comments

Comments
 (0)