Skip to content

Commit 8eba4ca

Browse files
committed
feat(profile): Add Shizuku-based auto-update functionality
- Implement background auto-update support using WorkManager and Shizuku silent installation. - Add `AutoUpdateWorker` and update `UpdateCheckWorker` to trigger automatic updates when enabled. - Add "Auto-update apps" toggle to the Profile screen's Installation section (visible when Shizuku is ready). - Update `PackageEventReceiver` to support both static (manifest) and dynamic registration to reliably track installs when the process is killed. - Improve `ShizukuServiceManager` with thread-safe binding using a Mutex and a 15-second timeout. - Add necessary foreground service permissions and notification channels for background update tasks. - Persist auto-update preference in `ThemesRepository`. - Increase network timeouts in `AndroidDownloader` for better reliability. - Provide localized strings for the auto-update feature across multiple languages.
1 parent 68ebb66 commit 8eba4ca

28 files changed

Lines changed: 447 additions & 57 deletions

File tree

composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
1515
tools:ignore="RequestInstallPackagesPolicy" />
1616
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
17+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
18+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
19+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
1720

1821
<application
1922
android:name=".app.GithubStoreApp"
@@ -101,6 +104,27 @@
101104
android:resource="@xml/filepaths" />
102105
</provider>
103106

107+
<!-- Reschedule update checks after device reboot -->
108+
<receiver
109+
android:name="zed.rainxch.core.data.services.BootReceiver"
110+
android:exported="false">
111+
<intent-filter>
112+
<action android:name="android.intent.action.BOOT_COMPLETED" />
113+
</intent-filter>
114+
</receiver>
115+
116+
<!-- Static receiver for package install/remove events (works even when process is dead) -->
117+
<receiver
118+
android:name="zed.rainxch.core.data.services.PackageEventReceiver"
119+
android:exported="false">
120+
<intent-filter>
121+
<action android:name="android.intent.action.PACKAGE_ADDED" />
122+
<action android:name="android.intent.action.PACKAGE_REPLACED" />
123+
<action android:name="android.intent.action.PACKAGE_FULLY_REMOVED" />
124+
<data android:scheme="package" />
125+
</intent-filter>
126+
</receiver>
127+
104128
<!-- Shizuku provider for optional silent install support -->
105129
<provider
106130
android:name="rikka.shizuku.ShizukuProvider"

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,28 @@ class GithubStoreApp : Application() {
2828
}
2929

3030
private fun createNotificationChannels() {
31-
val channel =
31+
val notificationManager = getSystemService(NotificationManager::class.java)
32+
33+
val updatesChannel =
3234
NotificationChannel(
3335
UPDATES_CHANNEL_ID,
3436
"App Updates",
3537
NotificationManager.IMPORTANCE_HIGH,
3638
).apply {
3739
description = "Notifications when app updates are available"
3840
}
39-
val notificationManager = getSystemService(NotificationManager::class.java)
40-
notificationManager.createNotificationChannel(channel)
41+
notificationManager.createNotificationChannel(updatesChannel)
42+
43+
val serviceChannel =
44+
NotificationChannel(
45+
UPDATE_SERVICE_CHANNEL_ID,
46+
"Update Service",
47+
NotificationManager.IMPORTANCE_LOW,
48+
).apply {
49+
description = "Background update check and auto-update progress"
50+
setShowBadge(false)
51+
}
52+
notificationManager.createNotificationChannel(serviceChannel)
4153
}
4254

4355
private fun registerPackageEventReceiver() {
@@ -63,5 +75,6 @@ class GithubStoreApp : Application() {
6375

6476
companion object {
6577
const val UPDATES_CHANNEL_ID = "app_updates"
78+
const val UPDATE_SERVICE_CHANNEL_ID = "update_service"
6679
}
6780
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import java.net.PasswordAuthentication
2121
import java.net.Proxy
2222
import java.util.UUID
2323
import java.util.concurrent.ConcurrentHashMap
24+
import java.util.concurrent.TimeUnit
2425

2526
class AndroidDownloader(
2627
private val files: FileLocationsProvider,
@@ -34,6 +35,9 @@ class AndroidDownloader(
3435

3536
return OkHttpClient
3637
.Builder()
38+
.connectTimeout(30, TimeUnit.SECONDS)
39+
.readTimeout(60, TimeUnit.SECONDS)
40+
.writeTimeout(60, TimeUnit.SECONDS)
3741
.apply {
3842
when (val config = proxyManager.currentProxyConfig.value) {
3943
is ProxyConfig.None -> {

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

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,44 @@ import kotlinx.coroutines.CoroutineScope
99
import kotlinx.coroutines.Dispatchers
1010
import kotlinx.coroutines.SupervisorJob
1111
import kotlinx.coroutines.launch
12+
import org.koin.core.component.KoinComponent
13+
import org.koin.core.component.inject
1214
import zed.rainxch.core.domain.repository.InstalledAppsRepository
1315
import zed.rainxch.core.domain.system.PackageMonitor
1416

15-
class PackageEventReceiver(
16-
private val installedAppsRepository: InstalledAppsRepository,
17-
private val packageMonitor: PackageMonitor,
18-
) : BroadcastReceiver() {
17+
/**
18+
* Listens for package install/replace/remove broadcasts to update tracked app state.
19+
*
20+
* Registered both statically (manifest — works when process is dead, e.g. after
21+
* Shizuku silent install) and dynamically (GithubStoreApp — immediate in-process delivery).
22+
*
23+
* Uses [KoinComponent] for the no-arg constructor path (manifest-registered).
24+
* The constructor with explicit dependencies is used for dynamic registration.
25+
*/
26+
class PackageEventReceiver() : BroadcastReceiver(), KoinComponent {
27+
private val installedAppsRepositoryKoin: InstalledAppsRepository by inject()
28+
private val packageMonitorKoin: PackageMonitor by inject()
29+
30+
// Explicitly provided dependencies (dynamic registration path)
31+
private var explicitRepository: InstalledAppsRepository? = null
32+
private var explicitMonitor: PackageMonitor? = null
33+
1934
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
2035

36+
constructor(
37+
installedAppsRepository: InstalledAppsRepository,
38+
packageMonitor: PackageMonitor,
39+
) : this() {
40+
this.explicitRepository = installedAppsRepository
41+
this.explicitMonitor = packageMonitor
42+
}
43+
44+
private fun getRepository(): InstalledAppsRepository =
45+
explicitRepository ?: installedAppsRepositoryKoin
46+
47+
private fun getMonitor(): PackageMonitor =
48+
explicitMonitor ?: packageMonitorKoin
49+
2150
override fun onReceive(
2251
context: Context?,
2352
intent: Intent?,
@@ -26,44 +55,50 @@ class PackageEventReceiver(
2655

2756
Logger.d { "PackageEventReceiver: ${intent.action} for $packageName" }
2857

29-
when (intent.action) {
30-
Intent.ACTION_PACKAGE_ADDED,
31-
Intent.ACTION_PACKAGE_REPLACED,
32-
-> {
33-
scope.launch { onPackageInstalled(packageName) }
34-
}
58+
try {
59+
when (intent.action) {
60+
Intent.ACTION_PACKAGE_ADDED,
61+
Intent.ACTION_PACKAGE_REPLACED,
62+
-> {
63+
scope.launch { onPackageInstalled(packageName) }
64+
}
3565

36-
Intent.ACTION_PACKAGE_FULLY_REMOVED -> {
37-
scope.launch { onPackageRemoved(packageName) }
66+
Intent.ACTION_PACKAGE_FULLY_REMOVED -> {
67+
scope.launch { onPackageRemoved(packageName) }
68+
}
3869
}
70+
} catch (e: Exception) {
71+
Logger.e { "PackageEventReceiver: Failed to handle ${intent.action}: ${e.message}" }
3972
}
4073
}
4174

4275
private suspend fun onPackageInstalled(packageName: String) {
4376
try {
44-
val app = installedAppsRepository.getAppByPackage(packageName) ?: return
77+
val repo = getRepository()
78+
val monitor = getMonitor()
79+
val app = repo.getAppByPackage(packageName) ?: return
4580

4681
if (app.isPendingInstall) {
47-
val systemInfo = packageMonitor.getInstalledPackageInfo(packageName)
82+
val systemInfo = monitor.getInstalledPackageInfo(packageName)
4883
if (systemInfo != null) {
4984
val expectedVersionCode = app.latestVersionCode ?: 0L
5085
val wasActuallyUpdated =
5186
expectedVersionCode > 0L &&
5287
systemInfo.versionCode >= expectedVersionCode
5388

5489
if (wasActuallyUpdated) {
55-
installedAppsRepository.updateAppVersion(
90+
repo.updateAppVersion(
5691
packageName = packageName,
5792
newTag = app.latestVersion ?: systemInfo.versionName,
5893
newAssetName = app.latestAssetName ?: "",
5994
newAssetUrl = app.latestAssetUrl ?: "",
6095
newVersionName = systemInfo.versionName,
6196
newVersionCode = systemInfo.versionCode,
6297
)
63-
installedAppsRepository.updatePendingStatus(packageName, false)
98+
repo.updatePendingStatus(packageName, false)
6499
Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" }
65100
} else {
66-
installedAppsRepository.updateApp(
101+
repo.updateApp(
67102
app.copy(
68103
isPendingInstall = false,
69104
installedVersionName = systemInfo.versionName,
@@ -82,13 +117,13 @@ class PackageEventReceiver(
82117
}
83118
}
84119
} else {
85-
installedAppsRepository.updatePendingStatus(packageName, false)
120+
repo.updatePendingStatus(packageName, false)
86121
Logger.i { "Resolved pending install via broadcast (no system info): $packageName" }
87122
}
88123
} else {
89-
val systemInfo = packageMonitor.getInstalledPackageInfo(packageName)
124+
val systemInfo = monitor.getInstalledPackageInfo(packageName)
90125
if (systemInfo != null) {
91-
installedAppsRepository.updateApp(
126+
repo.updateApp(
92127
app.copy(
93128
installedVersionName = systemInfo.versionName,
94129
installedVersionCode = systemInfo.versionCode,
@@ -104,7 +139,7 @@ class PackageEventReceiver(
104139

105140
private suspend fun onPackageRemoved(packageName: String) {
106141
try {
107-
installedAppsRepository.deleteInstalledApp(packageName)
142+
getRepository().deleteInstalledApp(packageName)
108143
Logger.i { "Removed uninstalled app via broadcast: $packageName" }
109144
} catch (e: Exception) {
110145
Logger.e { "PackageEventReceiver remove error for $packageName: ${e.message}" }

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

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@ import android.app.PendingIntent
66
import android.content.Context
77
import android.content.Intent
88
import android.content.pm.PackageManager
9+
import android.content.pm.ServiceInfo
910
import android.os.Build
1011
import androidx.core.app.NotificationCompat
1112
import androidx.core.app.NotificationManagerCompat
1213
import androidx.core.content.ContextCompat
1314
import androidx.work.CoroutineWorker
15+
import androidx.work.ForegroundInfo
1416
import androidx.work.WorkerParameters
1517
import co.touchlab.kermit.Logger
1618
import kotlinx.coroutines.flow.first
1719
import org.koin.core.component.KoinComponent
1820
import org.koin.core.component.inject
1921
import zed.rainxch.core.domain.repository.InstalledAppsRepository
22+
import zed.rainxch.core.domain.repository.ThemesRepository
23+
import zed.rainxch.core.domain.model.InstallerType
2024
import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase
2125

2226
/**
@@ -25,7 +29,8 @@ import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase
2529
* Runs via WorkManager on a configurable schedule (default: every 6 hours).
2630
* First syncs app state with the system package manager, then checks each
2731
* tracked app's GitHub repository for new releases.
28-
* Shows a notification when updates are found.
32+
* Shows a notification when updates are found, or triggers auto-update
33+
* if Shizuku silent install is enabled and auto-update preference is on.
2934
*/
3035
class UpdateCheckWorker(
3136
context: Context,
@@ -34,11 +39,15 @@ class UpdateCheckWorker(
3439
KoinComponent {
3540
private val installedAppsRepository: InstalledAppsRepository by inject()
3641
private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase by inject()
42+
private val themesRepository: ThemesRepository by inject()
3743

3844
override suspend fun doWork(): Result =
3945
try {
4046
Logger.i { "UpdateCheckWorker: Starting periodic update check" }
4147

48+
// Run as foreground service to prevent OS from killing the worker
49+
setForeground(createForegroundInfo("Checking for updates..."))
50+
4251
// First sync installed apps state with system
4352
val syncResult = syncInstalledAppsUseCase()
4453
if (syncResult.isFailure) {
@@ -48,8 +57,23 @@ class UpdateCheckWorker(
4857
// Check all tracked apps for updates
4958
installedAppsRepository.checkAllForUpdates()
5059

51-
// Show notification if any updates are available
52-
showUpdateNotificationIfNeeded()
60+
val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first()
61+
62+
if (appsWithUpdates.isNotEmpty()) {
63+
// Check if auto-update via Shizuku is enabled
64+
val autoUpdateEnabled = themesRepository.getAutoUpdateEnabled().first()
65+
val installerType = themesRepository.getInstallerType().first()
66+
67+
if (autoUpdateEnabled && installerType == InstallerType.SHIZUKU) {
68+
Logger.i { "UpdateCheckWorker: Auto-update enabled with Shizuku, scheduling AutoUpdateWorker for ${appsWithUpdates.size} apps" }
69+
UpdateScheduler.scheduleAutoUpdate(applicationContext)
70+
} else {
71+
// Show notification for manual update
72+
showUpdateNotification(appsWithUpdates)
73+
}
74+
} else {
75+
Logger.d { "UpdateCheckWorker: No updates available" }
76+
}
5377

5478
Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" }
5579
Result.success()
@@ -62,14 +86,31 @@ class UpdateCheckWorker(
6286
}
6387
}
6488

65-
@SuppressLint("MissingPermission") // Permission checked at runtime before notify()
66-
private suspend fun showUpdateNotificationIfNeeded() {
67-
val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first()
68-
if (appsWithUpdates.isEmpty()) {
69-
Logger.d { "UpdateCheckWorker: No updates available, skipping notification" }
70-
return
89+
private fun createForegroundInfo(message: String): ForegroundInfo {
90+
val notification =
91+
NotificationCompat
92+
.Builder(applicationContext, UPDATE_SERVICE_CHANNEL_ID)
93+
.setSmallIcon(android.R.drawable.stat_notify_sync)
94+
.setContentTitle("GitHub Store")
95+
.setContentText(message)
96+
.setPriority(NotificationCompat.PRIORITY_LOW)
97+
.setOngoing(true)
98+
.setSilent(true)
99+
.build()
100+
101+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
102+
ForegroundInfo(
103+
FOREGROUND_NOTIFICATION_ID,
104+
notification,
105+
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
106+
)
107+
} else {
108+
ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification)
71109
}
110+
}
72111

112+
@SuppressLint("MissingPermission") // Permission checked at runtime before notify()
113+
private suspend fun showUpdateNotification(appsWithUpdates: List<zed.rainxch.core.domain.model.InstalledApp>) {
73114
// Check notification permission for API 33+
74115
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
75116
val granted =
@@ -133,6 +174,8 @@ class UpdateCheckWorker(
133174
companion object {
134175
const val WORK_NAME = "github_store_update_check"
135176
private const val UPDATES_CHANNEL_ID = "app_updates"
177+
private const val UPDATE_SERVICE_CHANNEL_ID = "update_service"
136178
private const val NOTIFICATION_ID = 1001
179+
private const val FOREGROUND_NOTIFICATION_ID = 1003
137180
}
138181
}

0 commit comments

Comments
 (0)