Skip to content

Commit 7667aaf

Browse files
committed
refactor(updates): Simplify update check logic and improve UX
This commit refactors the update-checking mechanism to be faster and more reliable by removing the pre-download and APK parsing steps. It now compares version tags directly. It also enhances the user experience on the "Apps" screen with pull-to-refresh, better status indicators, and improved handling of pending and stale app installations. - **refactor(data)**: Simplified `checkForUpdate` logic in `InstalledAppsRepositoryImpl`. The check for updates now directly compares the normalized installed version tag with the latest release tag, removing the need to download the APK for version code comparison. This makes the process significantly faster and less error-prone. - **feat(apps)**: Added pull-to-refresh functionality on the Apps screen to manually trigger a sync and check for updates. - **feat(apps)**: Implemented a "last checked" timestamp on the Apps screen to inform the user when updates were last fetched. An automatic check now runs on a 30-minute cooldown. - **feat(domain)**: Added logic to `SyncInstalledAppsUseCase` to automatically clean up pending installs that have not been completed within 24 hours. - **feat(ui)**: Introduced a "Pending Install" badge on the app details screen for apps that are awaiting installation. The install buttons are now correctly disabled for pending installs. - **chore(android)**: Registered a `PackageEventReceiver` in the main `Application` class to respond to app install/uninstall events, ensuring the database is kept in sync with the system's state.
1 parent f678c99 commit 7667aaf

File tree

11 files changed

+282
-178
lines changed

11 files changed

+282
-178
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
package zed.rainxch.githubstore.app
22

33
import android.app.Application
4+
import android.os.Build
5+
import org.koin.android.ext.android.get
46
import org.koin.android.ext.koin.androidContext
7+
import zed.rainxch.core.data.services.PackageEventReceiver
8+
import zed.rainxch.core.domain.repository.InstalledAppsRepository
9+
import zed.rainxch.core.domain.system.PackageMonitor
510
import zed.rainxch.githubstore.app.di.initKoin
611

712
class GithubStoreApp : Application() {
813

14+
private var packageEventReceiver: PackageEventReceiver? = null
15+
916
override fun onCreate() {
1017
super.onCreate()
1118

1219
initKoin {
1320
androidContext(this@GithubStoreApp)
1421
}
22+
23+
registerPackageEventReceiver()
24+
}
25+
26+
private fun registerPackageEventReceiver() {
27+
val receiver = PackageEventReceiver(
28+
installedAppsRepository = get<InstalledAppsRepository>(),
29+
packageMonitor = get<PackageMonitor>()
30+
)
31+
val filter = PackageEventReceiver.createIntentFilter()
32+
33+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
34+
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
35+
} else {
36+
registerReceiver(receiver, filter)
37+
}
38+
39+
packageEventReceiver = receiver
1540
}
1641
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ val coreModule = module {
6363
installedAppsDao = get(),
6464
historyDao = get(),
6565
installer = get(),
66-
downloader = get(),
6766
httpClient = get()
6867
)
6968
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt

Lines changed: 8 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow
1212
import kotlinx.coroutines.flow.first
1313
import kotlinx.coroutines.flow.map
1414
import zed.rainxch.core.data.dto.ReleaseNetwork
15-
import zed.rainxch.core.data.dto.RepoByIdNetwork
15+
1616
import zed.rainxch.core.data.local.db.AppDatabase
1717
import zed.rainxch.core.data.local.db.dao.InstalledAppDao
1818
import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao
@@ -24,16 +24,13 @@ import zed.rainxch.core.domain.system.Installer
2424
import zed.rainxch.core.domain.model.GithubRelease
2525
import zed.rainxch.core.domain.model.InstallSource
2626
import zed.rainxch.core.domain.model.InstalledApp
27-
import zed.rainxch.core.domain.network.Downloader
2827
import zed.rainxch.core.domain.repository.InstalledAppsRepository
29-
import java.io.File
3028

3129
class InstalledAppsRepositoryImpl(
3230
private val database: AppDatabase,
3331
private val installedAppsDao: InstalledAppDao,
3432
private val historyDao: UpdateHistoryDao,
3533
private val installer: Installer,
36-
private val downloader: Downloader,
3734
private val httpClient: HttpClient
3835
) : InstalledAppsRepository {
3936

@@ -79,21 +76,6 @@ class InstalledAppsRepositoryImpl(
7976
installedAppsDao.deleteByPackageName(packageName)
8077
}
8178

82-
private suspend fun fetchDefaultBranch(owner: String, repo: String): String? {
83-
return try {
84-
val repoInfo = httpClient.executeRequest<RepoByIdNetwork> {
85-
get("/repos/$owner/$repo") {
86-
header(HttpHeaders.Accept, "application/vnd.github+json")
87-
}
88-
}.getOrNull()
89-
90-
repoInfo?.defaultBranch
91-
} catch (e: Exception) {
92-
Logger.e { "Failed to fetch default branch for $owner/$repo: ${e.message}" }
93-
null
94-
}
95-
}
96-
9779
private suspend fun fetchLatestPublishedRelease(
9880
owner: String,
9981
repo: String
@@ -125,14 +107,6 @@ class InstalledAppsRepositoryImpl(
125107
val app = installedAppsDao.getAppByPackage(packageName) ?: return false
126108

127109
try {
128-
val branch = fetchDefaultBranch(app.repoOwner, app.repoName)
129-
130-
if (branch == null) {
131-
Logger.w { "Could not determine default branch for ${app.repoOwner}/${app.repoName}" }
132-
installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis())
133-
return false
134-
}
135-
136110
val latestRelease = fetchLatestPublishedRelease(
137111
owner = app.repoOwner,
138112
repo = app.repoName
@@ -142,68 +116,16 @@ class InstalledAppsRepositoryImpl(
142116
val normalizedInstalledTag = normalizeVersion(app.installedVersion)
143117
val normalizedLatestTag = normalizeVersion(latestRelease.tagName)
144118

145-
if (normalizedInstalledTag == normalizedLatestTag) {
146-
installedAppsDao.updateVersionInfo(
147-
packageName = packageName,
148-
available = false,
149-
version = latestRelease.tagName,
150-
assetName = app.latestAssetName,
151-
assetUrl = app.latestAssetUrl,
152-
assetSize = app.latestAssetSize,
153-
releaseNotes = latestRelease.description ?: "",
154-
timestamp = System.currentTimeMillis(),
155-
latestVersionName = app.latestVersionName,
156-
latestVersionCode = app.latestVersionCode
157-
)
158-
return false
159-
}
160-
161119
val installableAssets = latestRelease.assets.filter { asset ->
162120
installer.isAssetInstallable(asset.name)
163121
}
164-
165122
val primaryAsset = installer.choosePrimaryAsset(installableAssets)
166123

167-
var isUpdateAvailable = true
168-
var latestVersionName: String? = null
169-
var latestVersionCode: Long? = null
170-
171-
if (primaryAsset != null) {
172-
val tempAssetName = primaryAsset.name + ".tmp"
173-
try {
174-
downloader.download(primaryAsset.downloadUrl, tempAssetName).collect { }
175-
176-
val tempPath = downloader.getDownloadedFilePath(tempAssetName)
177-
if (tempPath != null) {
178-
val latestInfo = try {
179-
installer.getApkInfoExtractor().extractPackageInfo(tempPath)
180-
} finally {
181-
File(tempPath).delete()
182-
}
183-
if (latestInfo != null) {
184-
latestVersionName = latestInfo.versionName
185-
latestVersionCode = latestInfo.versionCode
186-
isUpdateAvailable = latestVersionCode > app.installedVersionCode
187-
} else {
188-
latestVersionName = latestRelease.tagName
189-
}
190-
191-
} else {
192-
latestVersionName = latestRelease.tagName
193-
}
194-
} catch (e: Exception) {
195-
Logger.w { "Failed to download or extract APK for version check of ${app.packageName}: ${e.message}" }
196-
downloader.getDownloadedFilePath(tempAssetName)?.let { File(it).delete() }
197-
latestVersionName = latestRelease.tagName
198-
}
199-
} else {
200-
latestVersionName = latestRelease.tagName
201-
}
124+
val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag
202125

203126
Logger.d {
204-
"Update check for ${app.appName}: currentTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " +
205-
"currentCode=${app.installedVersionCode}, latestCode=$latestVersionCode, isUpdate=$isUpdateAvailable, " +
206-
"primaryAsset=${primaryAsset?.name}"
127+
"Update check for ${app.appName}: installedTag=${app.installedVersion}, " +
128+
"latestTag=${latestRelease.tagName}, isUpdate=$isUpdateAvailable"
207129
}
208130

209131
installedAppsDao.updateVersionInfo(
@@ -215,11 +137,13 @@ class InstalledAppsRepositoryImpl(
215137
assetSize = primaryAsset?.size,
216138
releaseNotes = latestRelease.description ?: "",
217139
timestamp = System.currentTimeMillis(),
218-
latestVersionName = latestVersionName,
219-
latestVersionCode = latestVersionCode
140+
latestVersionName = latestRelease.tagName,
141+
latestVersionCode = null
220142
)
221143

222144
return isUpdateAvailable
145+
} else {
146+
installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis())
223147
}
224148
} catch (e: Exception) {
225149
Logger.e { "Failed to check updates for $packageName: ${e.message}" }

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import zed.rainxch.core.domain.system.PackageMonitor
1111

1212
/**
1313
* Use case for synchronizing installed apps state with the system package manager.
14-
*
14+
*
1515
* Responsibilities:
1616
* 1. Remove apps from DB that are no longer installed on the system
1717
* 2. Migrate legacy apps missing versionName/versionCode fields
1818
* 3. Resolve pending installs once they appear in the system package manager
19-
*
19+
* 4. Clean up stale pending installs (older than 24 hours)
20+
*
2021
* This should be called before loading or refreshing app data to ensure consistency.
2122
*/
2223
class SyncInstalledAppsUseCase(
@@ -25,6 +26,9 @@ class SyncInstalledAppsUseCase(
2526
private val platform: Platform,
2627
private val logger: GitHubStoreLogger
2728
) {
29+
companion object {
30+
private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L // 24 hours
31+
}
2832
/**
2933
* Executes the sync operation.
3034
*
@@ -34,17 +38,21 @@ class SyncInstalledAppsUseCase(
3438
try {
3539
val installedPackageNames = packageMonitor.getAllInstalledPackageNames()
3640
val appsInDb = installedAppsRepository.getAllInstalledApps().first()
41+
val now = System.currentTimeMillis()
3742

3843
val toDelete = mutableListOf<String>()
3944
val toMigrate = mutableListOf<Pair<String, MigrationResult>>()
4045
val toResolvePending = mutableListOf<InstalledApp>()
46+
val toDeleteStalePending = mutableListOf<String>()
4147

4248
appsInDb.forEach { app ->
4349
val isOnSystem = installedPackageNames.contains(app.packageName)
4450
when {
4551
app.isPendingInstall -> {
4652
if (isOnSystem) {
4753
toResolvePending.add(app)
54+
} else if (now - app.installedAt > PENDING_TIMEOUT_MS) {
55+
toDeleteStalePending.add(app.packageName)
4856
}
4957
}
5058

@@ -69,6 +77,15 @@ class SyncInstalledAppsUseCase(
6977
}
7078
}
7179

80+
toDeleteStalePending.forEach { packageName ->
81+
try {
82+
installedAppsRepository.deleteInstalledApp(packageName)
83+
logger.info("Removed stale pending install (>24h): $packageName")
84+
} catch (e: Exception) {
85+
logger.error("Failed to delete stale pending $packageName: ${e.message}")
86+
}
87+
}
88+
7289
toResolvePending.forEach { app ->
7390
try {
7491
val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName)
@@ -116,7 +133,8 @@ class SyncInstalledAppsUseCase(
116133
}
117134

118135
logger.info(
119-
"Sync completed: ${toDelete.size} deleted, ${toResolvePending.size} pending resolved, ${toMigrate.size} migrated"
136+
"Sync completed: ${toDelete.size} deleted, ${toDeleteStalePending.size} stale pending removed, " +
137+
"${toResolvePending.size} pending resolved, ${toMigrate.size} migrated"
120138
)
121139

122140
Result.success(Unit)

core/presentation/src/commonMain/composeResources/values/strings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,12 @@
346346
<string name="latest_badge">Latest</string>
347347
<string name="no_version_selected">No version</string>
348348
<string name="versions_title">Versions</string>
349+
350+
<!-- Apps feature - Last checked -->
351+
<string name="last_checked">Last checked: %1$s</string>
352+
<string name="last_checked_never">Never checked</string>
353+
<string name="last_checked_just_now">just now</string>
354+
<string name="last_checked_minutes_ago">%1$d min ago</string>
355+
<string name="last_checked_hours_ago">%1$d h ago</string>
356+
<string name="checking_for_updates">Checking for updates…</string>
349357
</resources>

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ sealed interface AppsAction {
1111
data object OnUpdateAll : AppsAction
1212
data object OnCancelUpdateAll : AppsAction
1313
data object OnCheckAllForUpdates : AppsAction
14+
data object OnRefresh : AppsAction
1415
data class OnNavigateToRepo(val repoId: Long) : AppsAction
1516
}

0 commit comments

Comments
 (0)