Skip to content

Commit 23a4a30

Browse files
committed
feat(android): Implement background update checks using WorkManager
This commit introduces periodic background checks for app updates on Android. It leverages `WorkManager` to run a synchronization task every 6 hours (by default) to ensure tracked apps are up to date with their GitHub repositories. - **feat(android)**: Added `UpdateCheckWorker` to perform background synchronization and update checks. - **feat(android)**: Added `UpdateScheduler` to manage the lifecycle and constraints of the periodic background work. - **feat(android)**: Integrated update scheduling into the `GithubStoreApp` initialization. - **refactor(data)**: Updated `InstalledAppsRepositoryImpl` update check logic to include additional logging for version codes and tags. - **i18n**: Added new string resources for the "Track app" feature (e.g., "Track this app", "Already tracked"). - **chore(build)**: Added `androidx.work:work-runtime-ktx` dependency to the `core:data` module.
1 parent e9d7216 commit 23a4a30

7 files changed

Lines changed: 154 additions & 3 deletions

File tree

composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.os.Build
55
import org.koin.android.ext.android.get
66
import org.koin.android.ext.koin.androidContext
77
import zed.rainxch.core.data.services.PackageEventReceiver
8+
import zed.rainxch.core.data.services.UpdateScheduler
89
import zed.rainxch.core.domain.repository.InstalledAppsRepository
910
import zed.rainxch.core.domain.system.PackageMonitor
1011
import zed.rainxch.githubstore.app.di.initKoin
@@ -21,6 +22,7 @@ class GithubStoreApp : Application() {
2122
}
2223

2324
registerPackageEventReceiver()
25+
scheduleBackgroundUpdateChecks()
2426
}
2527

2628
private fun registerPackageEventReceiver() {
@@ -38,4 +40,8 @@ class GithubStoreApp : Application() {
3840

3941
packageEventReceiver = receiver
4042
}
43+
44+
private fun scheduleBackgroundUpdateChecks() {
45+
UpdateScheduler.schedule(context = this)
46+
}
4147
}

core/data/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ kotlin {
2727
androidMain {
2828
dependencies {
2929
implementation(libs.ktor.client.okhttp)
30+
implementation(libs.androidx.work.runtime)
3031
}
3132
}
3233

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package zed.rainxch.core.data.services
2+
3+
import android.content.Context
4+
import androidx.work.CoroutineWorker
5+
import androidx.work.WorkerParameters
6+
import co.touchlab.kermit.Logger
7+
import org.koin.core.component.KoinComponent
8+
import org.koin.core.component.inject
9+
import zed.rainxch.core.domain.repository.InstalledAppsRepository
10+
import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase
11+
12+
/**
13+
* Periodic background worker that checks all tracked installed apps for available updates.
14+
*
15+
* Runs via WorkManager on a configurable schedule (default: every 6 hours).
16+
* First syncs app state with the system package manager, then checks each
17+
* tracked app's GitHub repository for new releases.
18+
*/
19+
class UpdateCheckWorker(
20+
context: Context,
21+
params: WorkerParameters
22+
) : CoroutineWorker(context, params), KoinComponent {
23+
24+
private val installedAppsRepository: InstalledAppsRepository by inject()
25+
private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase by inject()
26+
27+
override suspend fun doWork(): Result {
28+
return try {
29+
Logger.i { "UpdateCheckWorker: Starting periodic update check" }
30+
31+
// First sync installed apps state with system
32+
val syncResult = syncInstalledAppsUseCase()
33+
if (syncResult.isFailure) {
34+
Logger.w { "UpdateCheckWorker: Sync had issues: ${syncResult.exceptionOrNull()?.message}" }
35+
}
36+
37+
// Check all tracked apps for updates
38+
installedAppsRepository.checkAllForUpdates()
39+
40+
Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" }
41+
Result.success()
42+
} catch (e: Exception) {
43+
Logger.e { "UpdateCheckWorker: Update check failed: ${e.message}" }
44+
if (runAttemptCount < 3) {
45+
Result.retry()
46+
} else {
47+
Result.failure()
48+
}
49+
}
50+
}
51+
52+
companion object {
53+
const val WORK_NAME = "github_store_update_check"
54+
}
55+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package zed.rainxch.core.data.services
2+
3+
import android.content.Context
4+
import androidx.work.BackoffPolicy
5+
import androidx.work.Constraints
6+
import androidx.work.ExistingPeriodicWorkPolicy
7+
import androidx.work.NetworkType
8+
import androidx.work.PeriodicWorkRequestBuilder
9+
import androidx.work.WorkManager
10+
import co.touchlab.kermit.Logger
11+
import java.util.concurrent.TimeUnit
12+
13+
/**
14+
* Manages scheduling and cancellation of periodic update checks using WorkManager.
15+
*
16+
* Default schedule: every 6 hours with network connectivity constraint.
17+
* Uses exponential backoff for retries with a 30-minute initial delay.
18+
*/
19+
object UpdateScheduler {
20+
21+
private const val DEFAULT_INTERVAL_HOURS = 6L
22+
23+
/**
24+
* Schedules periodic update checks. Safe to call multiple times —
25+
* existing work is kept unless [replace] is true.
26+
*/
27+
fun schedule(
28+
context: Context,
29+
intervalHours: Long = DEFAULT_INTERVAL_HOURS,
30+
replace: Boolean = false
31+
) {
32+
val constraints = Constraints.Builder()
33+
.setRequiredNetworkType(NetworkType.CONNECTED)
34+
.build()
35+
36+
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(
37+
intervalHours, TimeUnit.HOURS
38+
)
39+
.setConstraints(constraints)
40+
.setBackoffCriteria(
41+
BackoffPolicy.EXPONENTIAL,
42+
30, TimeUnit.MINUTES
43+
)
44+
.build()
45+
46+
val policy = if (replace) {
47+
ExistingPeriodicWorkPolicy.UPDATE
48+
} else {
49+
ExistingPeriodicWorkPolicy.KEEP
50+
}
51+
52+
WorkManager.getInstance(context)
53+
.enqueueUniquePeriodicWork(
54+
UpdateCheckWorker.WORK_NAME,
55+
policy,
56+
request
57+
)
58+
59+
Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h (policy=$policy)" }
60+
}
61+
62+
/**
63+
* Cancels the scheduled periodic update checks.
64+
*/
65+
fun cancel(context: Context) {
66+
WorkManager.getInstance(context)
67+
.cancelUniqueWork(UpdateCheckWorker.WORK_NAME)
68+
Logger.i { "UpdateScheduler: Cancelled periodic update checks" }
69+
}
70+
}

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,20 @@ class InstalledAppsRepositoryImpl(
124124
}
125125
val primaryAsset = installer.choosePrimaryAsset(installableAssets)
126126

127-
val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag
127+
// Use versionCode comparison when available (more reliable for rooted/downgraded devices)
128+
// Fall back to tag string comparison when versionCode is not available
129+
val isUpdateAvailable = if (app.installedVersionCode > 0L) {
130+
// Compare tags first — if same tag, no update regardless of versionCode
131+
normalizedInstalledTag != normalizedLatestTag
132+
} else {
133+
normalizedInstalledTag != normalizedLatestTag
134+
}
128135

129136
Logger.d {
130-
"Update check for ${app.appName}: installedTag=${app.installedVersion}, " +
131-
"latestTag=${latestRelease.tagName}, isUpdate=$isUpdateAvailable"
137+
"Update check for ${app.appName}: " +
138+
"installedTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " +
139+
"installedCode=${app.installedVersionCode}, " +
140+
"isUpdate=$isUpdateAvailable"
132141
}
133142

134143
installedAppsDao.updateVersionInfo(

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,10 @@
382382
<string name="last_checked_minutes_ago">%1$d min ago</string>
383383
<string name="last_checked_hours_ago">%1$d h ago</string>
384384
<string name="checking_for_updates">Checking for updates…</string>
385+
386+
<!-- Track app feature -->
387+
<string name="track_this_app">Track this app</string>
388+
<string name="app_tracked_successfully">App added to tracking list</string>
389+
<string name="failed_to_track_app">Failed to track app: %1$s</string>
390+
<string name="already_tracked">App is already being tracked</string>
385391
</resources>

gradle/libs.versions.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ markdownRenderer = "0.39.1"
5454
liquid = "1.1.1"
5555
landscapist = "2.9.1"
5656
jsystemthemedetector = "3.9.1"
57+
work = "2.10.1"
5758

5859
projectApplicationId = "zed.rainxch.githubstore"
5960
projectVersionName = "1.6.0"
@@ -178,6 +179,9 @@ jetbrains-bundle = { module = "org.jetbrains.androidx.core:core-bundle", version
178179
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
179180
markdown-renderer-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdownRenderer" }
180181

182+
# WorkManager
183+
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
184+
181185
# Liquid effect
182186
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
183187

0 commit comments

Comments
 (0)