Skip to content

Commit abaa77d

Browse files
committed
feat(core/data): add AutoUpdateWorker and BootReceiver for Android
- Implement `AutoUpdateWorker` to handle background downloads and silent installs via Shizuku. - Add `BootReceiver` to reschedule periodic update checks upon device reboot. - Integrate foreground notification service for update progress and summary reporting.
1 parent 8eba4ca commit abaa77d

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package zed.rainxch.core.data.services
2+
3+
import android.Manifest
4+
import android.annotation.SuppressLint
5+
import android.app.PendingIntent
6+
import android.content.Context
7+
import android.content.Intent
8+
import android.content.pm.PackageManager
9+
import android.content.pm.ServiceInfo
10+
import android.os.Build
11+
import androidx.core.app.NotificationCompat
12+
import androidx.core.app.NotificationManagerCompat
13+
import androidx.core.content.ContextCompat
14+
import androidx.work.CoroutineWorker
15+
import androidx.work.ForegroundInfo
16+
import androidx.work.WorkerParameters
17+
import co.touchlab.kermit.Logger
18+
import kotlinx.coroutines.flow.first
19+
import org.koin.core.component.KoinComponent
20+
import org.koin.core.component.inject
21+
import zed.rainxch.core.domain.model.InstalledApp
22+
import zed.rainxch.core.domain.model.InstallerType
23+
import zed.rainxch.core.domain.network.Downloader
24+
import zed.rainxch.core.domain.repository.InstalledAppsRepository
25+
import zed.rainxch.core.domain.repository.ThemesRepository
26+
import zed.rainxch.core.domain.system.Installer
27+
import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager
28+
import zed.rainxch.core.data.services.shizuku.ShizukuStatus
29+
30+
/**
31+
* Background worker that automatically downloads and silently installs
32+
* available updates via Shizuku.
33+
*
34+
* Only runs when auto-update is enabled AND Shizuku installer is selected and READY.
35+
* Falls back gracefully: if Shizuku becomes unavailable mid-update, remaining apps
36+
* are skipped and a notification is shown for manual update.
37+
*/
38+
class AutoUpdateWorker(
39+
context: Context,
40+
params: WorkerParameters,
41+
) : CoroutineWorker(context, params),
42+
KoinComponent {
43+
private val installedAppsRepository: InstalledAppsRepository by inject()
44+
private val installer: Installer by inject()
45+
private val downloader: Downloader by inject()
46+
private val themesRepository: ThemesRepository by inject()
47+
private val shizukuServiceManager: ShizukuServiceManager by inject()
48+
49+
override suspend fun doWork(): Result =
50+
try {
51+
Logger.i { "AutoUpdateWorker: Starting auto-update" }
52+
53+
// Double-check preferences (they may have changed since scheduling)
54+
val autoUpdateEnabled = themesRepository.getAutoUpdateEnabled().first()
55+
val installerType = themesRepository.getInstallerType().first()
56+
57+
// Refresh Shizuku status directly — don't rely on app-process cached state,
58+
// since this worker may run in a cold process where listeners weren't initialized.
59+
shizukuServiceManager.refreshStatus()
60+
val shizukuReady = shizukuServiceManager.status.value == ShizukuStatus.READY
61+
62+
if (!autoUpdateEnabled || installerType != InstallerType.SHIZUKU || !shizukuReady) {
63+
Logger.i { "AutoUpdateWorker: Conditions not met (autoUpdate=$autoUpdateEnabled, installer=$installerType, shizuku=$shizukuReady), skipping" }
64+
return Result.success()
65+
}
66+
67+
val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first()
68+
if (appsWithUpdates.isEmpty()) {
69+
Logger.d { "AutoUpdateWorker: No apps need updating" }
70+
return Result.success()
71+
}
72+
73+
setForeground(createForegroundInfo("Updating apps...", 0, appsWithUpdates.size))
74+
75+
val successfulApps = mutableListOf<String>()
76+
val failedApps = mutableListOf<String>()
77+
78+
appsWithUpdates.forEachIndexed { index, app ->
79+
setForeground(
80+
createForegroundInfo(
81+
"Updating ${app.appName}...",
82+
index + 1,
83+
appsWithUpdates.size,
84+
),
85+
)
86+
87+
try {
88+
updateApp(app)
89+
successfulApps.add(app.appName)
90+
Logger.i { "AutoUpdateWorker: Successfully updated ${app.appName}" }
91+
} catch (e: Exception) {
92+
failedApps.add(app.appName)
93+
Logger.e { "AutoUpdateWorker: Failed to update ${app.appName}: ${e.message}" }
94+
// Clear pending status on failure
95+
try {
96+
installedAppsRepository.updatePendingStatus(app.packageName, false)
97+
} catch (clearEx: Exception) {
98+
Logger.e { "AutoUpdateWorker: Failed to clear pending status: ${clearEx.message}" }
99+
}
100+
}
101+
}
102+
103+
// Show summary notification
104+
showSummaryNotification(successfulApps, failedApps)
105+
106+
Logger.i { "AutoUpdateWorker: Completed. Success: ${successfulApps.size}, Failed: ${failedApps.size}" }
107+
Result.success()
108+
} catch (e: Exception) {
109+
Logger.e { "AutoUpdateWorker: Fatal error: ${e.message}" }
110+
if (runAttemptCount < 2) {
111+
Result.retry()
112+
} else {
113+
Result.failure()
114+
}
115+
}
116+
117+
private suspend fun updateApp(app: InstalledApp) {
118+
val assetUrl = app.latestAssetUrl
119+
?: throw IllegalStateException("No asset URL for ${app.appName}")
120+
val assetName = app.latestAssetName
121+
?: throw IllegalStateException("No asset name for ${app.appName}")
122+
val latestVersion = app.latestVersion
123+
?: throw IllegalStateException("No latest version for ${app.appName}")
124+
125+
val ext = assetName.substringAfterLast('.', "").lowercase()
126+
127+
// Check if we already have a valid downloaded file
128+
val existingPath = downloader.getDownloadedFilePath(assetName)
129+
if (existingPath != null) {
130+
val file = java.io.File(existingPath)
131+
try {
132+
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(existingPath)
133+
val normalizedExisting = apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: ""
134+
val normalizedLatest = latestVersion.removePrefix("v").removePrefix("V")
135+
if (normalizedExisting != normalizedLatest) {
136+
file.delete()
137+
Logger.d { "AutoUpdateWorker: Deleted mismatched existing file for ${app.appName}" }
138+
}
139+
} catch (e: Exception) {
140+
file.delete()
141+
Logger.d { "AutoUpdateWorker: Deleted unextractable existing file for ${app.appName}" }
142+
}
143+
}
144+
145+
// Download the APK
146+
Logger.d { "AutoUpdateWorker: Downloading $assetName for ${app.appName}" }
147+
downloader.download(assetUrl, assetName).collect { /* consume flow to completion */ }
148+
149+
val filePath = downloader.getDownloadedFilePath(assetName)
150+
?: throw IllegalStateException("Downloaded file not found for ${app.appName}")
151+
152+
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
153+
?: throw IllegalStateException("Failed to extract APK info for ${app.appName}")
154+
155+
// Mark as pending install
156+
val currentApp = installedAppsRepository.getAppByPackage(app.packageName)
157+
if (currentApp != null) {
158+
installedAppsRepository.updateApp(
159+
currentApp.copy(
160+
isPendingInstall = true,
161+
latestVersion = latestVersion,
162+
latestAssetName = assetName,
163+
latestAssetUrl = assetUrl,
164+
latestVersionName = apkInfo.versionName,
165+
latestVersionCode = apkInfo.versionCode,
166+
),
167+
)
168+
}
169+
170+
// Silent install via Shizuku (ShizukuInstallerWrapper handles the actual Shizuku call)
171+
Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" }
172+
try {
173+
installer.install(filePath, ext)
174+
} catch (e: Exception) {
175+
installedAppsRepository.updatePendingStatus(app.packageName, false)
176+
throw e
177+
}
178+
179+
Logger.d { "AutoUpdateWorker: Install command completed for ${app.appName}, waiting for system confirmation via broadcast" }
180+
}
181+
182+
private fun createForegroundInfo(
183+
message: String,
184+
current: Int,
185+
total: Int,
186+
): ForegroundInfo {
187+
val builder =
188+
NotificationCompat
189+
.Builder(applicationContext, UPDATE_SERVICE_CHANNEL_ID)
190+
.setSmallIcon(android.R.drawable.stat_sys_download)
191+
.setContentTitle("GitHub Store")
192+
.setContentText(message)
193+
.setPriority(NotificationCompat.PRIORITY_LOW)
194+
.setOngoing(true)
195+
.setSilent(true)
196+
197+
if (total > 0) {
198+
builder.setProgress(total, current, false)
199+
}
200+
201+
val notification = builder.build()
202+
203+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
204+
ForegroundInfo(
205+
FOREGROUND_NOTIFICATION_ID,
206+
notification,
207+
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
208+
)
209+
} else {
210+
ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification)
211+
}
212+
}
213+
214+
@SuppressLint("MissingPermission")
215+
private fun showSummaryNotification(
216+
successfulApps: List<String>,
217+
failedApps: List<String>,
218+
) {
219+
// Check notification permission for API 33+
220+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
221+
val granted =
222+
ContextCompat.checkSelfPermission(
223+
applicationContext,
224+
Manifest.permission.POST_NOTIFICATIONS,
225+
) == PackageManager.PERMISSION_GRANTED
226+
if (!granted) return
227+
}
228+
229+
if (successfulApps.isEmpty() && failedApps.isEmpty()) return
230+
231+
val title = when {
232+
failedApps.isEmpty() -> "${successfulApps.size} app${if (successfulApps.size > 1) "s" else ""} updated"
233+
successfulApps.isEmpty() -> "Failed to update ${failedApps.size} app${if (failedApps.size > 1) "s" else ""}"
234+
else -> "${successfulApps.size} updated, ${failedApps.size} failed"
235+
}
236+
237+
val text = when {
238+
failedApps.isEmpty() -> successfulApps.joinToString(", ")
239+
successfulApps.isEmpty() -> failedApps.joinToString(", ")
240+
else -> "Updated: ${successfulApps.joinToString(", ")}. Failed: ${failedApps.joinToString(", ")}"
241+
}
242+
243+
val launchIntent =
244+
applicationContext.packageManager
245+
.getLaunchIntentForPackage(applicationContext.packageName)
246+
?.apply {
247+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
248+
}
249+
250+
val pendingIntent =
251+
launchIntent?.let {
252+
PendingIntent.getActivity(
253+
applicationContext,
254+
0,
255+
it,
256+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
257+
)
258+
}
259+
260+
val notification =
261+
NotificationCompat
262+
.Builder(applicationContext, UPDATES_CHANNEL_ID)
263+
.setSmallIcon(
264+
if (failedApps.isEmpty()) {
265+
android.R.drawable.stat_sys_download_done
266+
} else {
267+
android.R.drawable.stat_notify_error
268+
},
269+
)
270+
.setContentTitle(title)
271+
.setContentText(text)
272+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
273+
.setContentIntent(pendingIntent)
274+
.setAutoCancel(true)
275+
.build()
276+
277+
NotificationManagerCompat.from(applicationContext).notify(SUMMARY_NOTIFICATION_ID, notification)
278+
}
279+
280+
companion object {
281+
const val WORK_NAME = "github_store_auto_update"
282+
private const val UPDATES_CHANNEL_ID = "app_updates"
283+
private const val UPDATE_SERVICE_CHANNEL_ID = "update_service"
284+
private const val FOREGROUND_NOTIFICATION_ID = 1004
285+
private const val SUMMARY_NOTIFICATION_ID = 1005
286+
}
287+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package zed.rainxch.core.data.services
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import co.touchlab.kermit.Logger
7+
8+
/**
9+
* Reschedules periodic update checks after device reboot.
10+
* Registered statically in AndroidManifest.xml.
11+
*/
12+
class BootReceiver : BroadcastReceiver() {
13+
override fun onReceive(context: Context, intent: Intent?) {
14+
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
15+
Logger.i { "BootReceiver: Device booted, scheduling update checks" }
16+
UpdateScheduler.schedule(context)
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)