Skip to content

Commit 79bffe7

Browse files
committed
feat(android): Add background update notifications
This commit introduces notifications for available app updates found during the periodic background check. When the `UpdateCheckWorker` finds new versions, it will now display a system notification. It also improves the reliability of tracking installations initiated by the app. - **feat(android)**: Added `showUpdateNotificationIfNeeded` to `UpdateCheckWorker` to display a system notification when one or more app updates are available. - **feat(android)**: Implemented runtime permission checks for `POST_NOTIFICATIONS` on Android 13 (API 33) and higher. - **feat(android)**: Created a "App Updates" notification channel during application startup. - **refactor(android)**: In `PackageEventReceiver`, improved logic to more reliably confirm an update by comparing the installed version code against the expected version code from the latest release. - **refactor(apps)**: The app now waits for the `PackageEventReceiver` to confirm an installation via a system broadcast, rather than immediately marking an update as successful after the installer is launched. - **chore(android)**: Added the `POST_NOTIFICATIONS` permission to `AndroidManifest.xml`. - **chore(android)**: Removed descriptive KDoc comments from `UpdateScheduler` and `PackageEventReceiver` to rely on code clarity.
1 parent 892c347 commit 79bffe7

6 files changed

Lines changed: 155 additions & 51 deletions

File tree

composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44

55
<uses-permission android:name="android.permission.INTERNET" />
66

7+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8+
79
<uses-permission
810
android:name="android.permission.QUERY_ALL_PACKAGES"
911
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
1012

1113
<uses-permission
1214
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
1315
tools:ignore="RequestInstallPackagesPolicy" />
14-
1516
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
1617

1718
<application
@@ -66,8 +67,7 @@
6667
<data android:mimeType="text/html" />
6768
</intent-filter>
6869

69-
<!-- GitHub repository links: https://github.com/{owner}/{repo} -->
70-
<intent-filter>
70+
<intent-filter android:autoVerify="false">
7171
<action android:name="android.intent.action.VIEW" />
7272
<category android:name="android.intent.category.DEFAULT" />
7373
<category android:name="android.intent.category.BROWSABLE" />

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package zed.rainxch.githubstore.app
22

33
import android.app.Application
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
46
import android.os.Build
57
import org.koin.android.ext.android.get
68
import org.koin.android.ext.koin.androidContext
@@ -21,10 +23,23 @@ class GithubStoreApp : Application() {
2123
androidContext(this@GithubStoreApp)
2224
}
2325

26+
createNotificationChannels()
2427
registerPackageEventReceiver()
2528
scheduleBackgroundUpdateChecks()
2629
}
2730

31+
private fun createNotificationChannels() {
32+
val channel = NotificationChannel(
33+
UPDATES_CHANNEL_ID,
34+
"App Updates",
35+
NotificationManager.IMPORTANCE_HIGH
36+
).apply {
37+
description = "Notifications when app updates are available"
38+
}
39+
val notificationManager = getSystemService(NotificationManager::class.java)
40+
notificationManager.createNotificationChannel(channel)
41+
}
42+
2843
private fun registerPackageEventReceiver() {
2944
val receiver = PackageEventReceiver(
3045
installedAppsRepository = get<InstalledAppsRepository>(),
@@ -44,4 +59,8 @@ class GithubStoreApp : Application() {
4459
private fun scheduleBackgroundUpdateChecks() {
4560
UpdateScheduler.schedule(context = this)
4661
}
62+
63+
companion object {
64+
const val UPDATES_CHANNEL_ID = "app_updates"
65+
}
4766
}

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ import kotlinx.coroutines.launch
1212
import zed.rainxch.core.domain.repository.InstalledAppsRepository
1313
import zed.rainxch.core.domain.system.PackageMonitor
1414

15-
/**
16-
* Listens to system package install/uninstall/replace broadcasts.
17-
* When a tracked package is installed or updated, it resolves the pending
18-
* install flag and updates version info from the system PackageManager.
19-
* When a tracked package is removed, it deletes the record from the database.
20-
*/
2115
class PackageEventReceiver(
2216
private val installedAppsRepository: InstalledAppsRepository,
2317
private val packageMonitor: PackageMonitor
@@ -49,17 +43,37 @@ class PackageEventReceiver(
4943
if (app.isPendingInstall) {
5044
val systemInfo = packageMonitor.getInstalledPackageInfo(packageName)
5145
if (systemInfo != null) {
52-
installedAppsRepository.updateApp(
53-
app.copy(
54-
isPendingInstall = false,
55-
isUpdateAvailable = false,
56-
installedVersionName = systemInfo.versionName,
57-
installedVersionCode = systemInfo.versionCode,
58-
latestVersionName = systemInfo.versionName,
59-
latestVersionCode = systemInfo.versionCode
46+
val expectedVersionCode = app.latestVersionCode ?: 0L
47+
val wasActuallyUpdated = expectedVersionCode > 0L &&
48+
systemInfo.versionCode >= expectedVersionCode
49+
50+
if (wasActuallyUpdated) {
51+
installedAppsRepository.updateAppVersion(
52+
packageName = packageName,
53+
newTag = app.latestVersion ?: systemInfo.versionName,
54+
newAssetName = app.latestAssetName ?: "",
55+
newAssetUrl = app.latestAssetUrl ?: "",
56+
newVersionName = systemInfo.versionName,
57+
newVersionCode = systemInfo.versionCode
6058
)
61-
)
62-
Logger.i { "Resolved pending install via broadcast: $packageName (v${systemInfo.versionName})" }
59+
installedAppsRepository.updatePendingStatus(packageName, false)
60+
Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" }
61+
} else {
62+
installedAppsRepository.updateApp(
63+
app.copy(
64+
isPendingInstall = false,
65+
installedVersionName = systemInfo.versionName,
66+
installedVersionCode = systemInfo.versionCode,
67+
isUpdateAvailable = (app.latestVersionCode
68+
?: 0L) > systemInfo.versionCode
69+
)
70+
)
71+
Logger.i {
72+
"Package replaced but not updated to target: $packageName " +
73+
"(system: v${systemInfo.versionName}/${systemInfo.versionCode}, " +
74+
"target: v${app.latestVersionName}/${app.latestVersionCode})"
75+
}
76+
}
6377
} else {
6478
installedAppsRepository.updatePendingStatus(packageName, false)
6579
Logger.i { "Resolved pending install via broadcast (no system info): $packageName" }
@@ -83,7 +97,6 @@ class PackageEventReceiver(
8397

8498
private suspend fun onPackageRemoved(packageName: String) {
8599
try {
86-
val app = installedAppsRepository.getAppByPackage(packageName) ?: return
87100
installedAppsRepository.deleteInstalledApp(packageName)
88101
Logger.i { "Removed uninstalled app via broadcast: $packageName" }
89102
} catch (e: Exception) {

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
package zed.rainxch.core.data.services
22

3+
import android.Manifest
4+
import android.annotation.SuppressLint
5+
import android.app.PendingIntent
36
import android.content.Context
7+
import android.content.Intent
8+
import android.content.pm.PackageManager
9+
import android.os.Build
10+
import androidx.core.app.NotificationCompat
11+
import androidx.core.app.NotificationManagerCompat
12+
import androidx.core.content.ContextCompat
413
import androidx.work.CoroutineWorker
514
import androidx.work.WorkerParameters
615
import co.touchlab.kermit.Logger
16+
import kotlinx.coroutines.flow.first
717
import org.koin.core.component.KoinComponent
818
import org.koin.core.component.inject
919
import zed.rainxch.core.domain.repository.InstalledAppsRepository
@@ -15,6 +25,7 @@ import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase
1525
* Runs via WorkManager on a configurable schedule (default: every 6 hours).
1626
* First syncs app state with the system package manager, then checks each
1727
* tracked app's GitHub repository for new releases.
28+
* Shows a notification when updates are found.
1829
*/
1930
class UpdateCheckWorker(
2031
context: Context,
@@ -37,6 +48,9 @@ class UpdateCheckWorker(
3748
// Check all tracked apps for updates
3849
installedAppsRepository.checkAllForUpdates()
3950

51+
// Show notification if any updates are available
52+
showUpdateNotificationIfNeeded()
53+
4054
Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" }
4155
Result.success()
4256
} catch (e: Exception) {
@@ -49,7 +63,70 @@ class UpdateCheckWorker(
4963
}
5064
}
5165

66+
@SuppressLint("MissingPermission") // Permission checked at runtime before notify()
67+
private suspend fun showUpdateNotificationIfNeeded() {
68+
val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first()
69+
if (appsWithUpdates.isEmpty()) {
70+
Logger.d { "UpdateCheckWorker: No updates available, skipping notification" }
71+
return
72+
}
73+
74+
// Check notification permission for API 33+
75+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
76+
val granted = ContextCompat.checkSelfPermission(
77+
applicationContext,
78+
Manifest.permission.POST_NOTIFICATIONS
79+
) == PackageManager.PERMISSION_GRANTED
80+
if (!granted) {
81+
Logger.w { "UpdateCheckWorker: POST_NOTIFICATIONS permission not granted, skipping notification" }
82+
return
83+
}
84+
}
85+
86+
val title = if (appsWithUpdates.size == 1) {
87+
"${appsWithUpdates.first().appName} update available"
88+
} else {
89+
"${appsWithUpdates.size} app updates available"
90+
}
91+
92+
val text = if (appsWithUpdates.size == 1) {
93+
val app = appsWithUpdates.first()
94+
"${app.installedVersion}${app.latestVersion}"
95+
} else {
96+
appsWithUpdates.joinToString(", ") { it.appName }
97+
}
98+
99+
val launchIntent = applicationContext.packageManager
100+
.getLaunchIntentForPackage(applicationContext.packageName)
101+
?.apply {
102+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
103+
}
104+
105+
val pendingIntent = launchIntent?.let {
106+
PendingIntent.getActivity(
107+
applicationContext,
108+
0,
109+
it,
110+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
111+
)
112+
}
113+
114+
val notification = NotificationCompat.Builder(applicationContext, UPDATES_CHANNEL_ID)
115+
.setSmallIcon(android.R.drawable.stat_sys_download_done)
116+
.setContentTitle(title)
117+
.setContentText(text)
118+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
119+
.setContentIntent(pendingIntent)
120+
.setAutoCancel(true)
121+
.build()
122+
123+
NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification)
124+
Logger.i { "UpdateCheckWorker: Showed notification for ${appsWithUpdates.size} updates" }
125+
}
126+
52127
companion object {
53128
const val WORK_NAME = "github_store_update_check"
129+
private const val UPDATES_CHANNEL_ID = "app_updates"
130+
private const val NOTIFICATION_ID = 1001
54131
}
55132
}

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,10 @@ import androidx.work.WorkManager
1010
import co.touchlab.kermit.Logger
1111
import java.util.concurrent.TimeUnit
1212

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-
*/
1913
object UpdateScheduler {
2014

2115
private const val DEFAULT_INTERVAL_HOURS = 6L
2216

23-
/**
24-
* Schedules periodic update checks. Safe to call multiple times —
25-
* existing work is kept unless [replace] is true.
26-
*/
2717
fun schedule(
2818
context: Context,
2919
intervalHours: Long = DEFAULT_INTERVAL_HOURS,
@@ -34,7 +24,8 @@ object UpdateScheduler {
3424
.build()
3525

3626
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(
37-
intervalHours, TimeUnit.HOURS
27+
repeatInterval = intervalHours,
28+
repeatIntervalTimeUnit = TimeUnit.HOURS
3829
)
3930
.setConstraints(constraints)
4031
.setBackoffCriteria(
@@ -51,17 +42,14 @@ object UpdateScheduler {
5142

5243
WorkManager.getInstance(context)
5344
.enqueueUniquePeriodicWork(
54-
UpdateCheckWorker.WORK_NAME,
55-
policy,
56-
request
45+
uniqueWorkName = UpdateCheckWorker.WORK_NAME,
46+
existingPeriodicWorkPolicy = policy,
47+
request = request
5748
)
5849

5950
Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h (policy=$policy)" }
6051
}
6152

62-
/**
63-
* Cancels the scheduled periodic update checks.
64-
*/
6553
fun cancel(context: Context) {
6654
WorkManager.getInstance(context)
6755
.cancelUniqueWork(UpdateCheckWorker.WORK_NAME)

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

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package zed.rainxch.apps.presentation
22

3-
import androidx.compose.runtime.remember
43
import androidx.lifecycle.ViewModel
54
import androidx.lifecycle.viewModelScope
65
import zed.rainxch.githubstore.core.presentation.res.*
@@ -356,7 +355,23 @@ class AppsViewModel(
356355
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
357356
?: throw IllegalStateException("Failed to extract APK info")
358357

359-
markPendingUpdate(app)
358+
// Save latest release metadata and mark as pending install
359+
// so PackageEventReceiver can verify the actual installation
360+
val currentApp = installedAppsRepository.getAppByPackage(app.packageName)
361+
if (currentApp != null) {
362+
installedAppsRepository.updateApp(
363+
currentApp.copy(
364+
isPendingInstall = true,
365+
latestVersion = latestVersion,
366+
latestAssetName = latestAssetName,
367+
latestAssetUrl = latestAssetUrl,
368+
latestVersionName = apkInfo.versionName,
369+
latestVersionCode = apkInfo.versionCode
370+
)
371+
)
372+
} else {
373+
markPendingUpdate(app)
374+
}
360375

361376
updateAppState(app.packageName, UpdateState.Installing)
362377

@@ -367,20 +382,12 @@ class AppsViewModel(
367382
throw e
368383
}
369384

370-
installedAppsRepository.updateAppVersion(
371-
packageName = app.packageName,
372-
newTag = latestVersion,
373-
newAssetName = latestAssetName,
374-
newAssetUrl = latestAssetUrl,
375-
newVersionName = apkInfo.versionName,
376-
newVersionCode = apkInfo.versionCode
377-
)
378-
379-
updateAppState(app.packageName, UpdateState.Success)
380-
delay(2000)
385+
// Don't mark as updated here — installer.install() just launches the
386+
// system install dialog and returns immediately. PackageEventReceiver
387+
// will handle confirming the actual installation via broadcast.
381388
updateAppState(app.packageName, UpdateState.Idle)
382389

383-
logger.debug("Successfully updated ${app.appName} to ${latestVersion}")
390+
logger.debug("Launched installer for ${app.appName} ${latestVersion}, waiting for system confirmation")
384391

385392
} catch (e: CancellationException) {
386393
logger.debug("Update cancelled for ${app.packageName}")

0 commit comments

Comments
 (0)