Skip to content

Commit 19d1635

Browse files
committed
feat: implement APK signing fingerprint verification and improve Shizuku integration
- Add `signingFingerprint` field to `InstalledAppEntity`, `InstalledApp`, and `ApkPackageInfo` to track app authenticity - Implement fingerprint verification during the installation process to prevent updates with mismatched signing keys - Enhance `AndroidInstallerInfoExtractor` to extract SHA-256 signing certificates for both modern (API 28+) and legacy Android versions - Refactor `DetailsViewModel` to decouple download and installation logic into distinct, manageable methods - Update `InstalledAppsRepository` and `PackageEventReceiver` to persist and verify signing fingerprints during updates - Improve Shizuku service management by moving `ShizukuStatus` to a dedicated model and refining lifecycle handling - Clean up `AutoUpdateWorker` and `UpdateScheduler` logic, including better notification handling and permission checks - Apply consistent code formatting and remove redundant comments across modified files
1 parent 75a5796 commit 19d1635

16 files changed

Lines changed: 553 additions & 426 deletions

File tree

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

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package zed.rainxch.core.data.services
22

33
import android.content.Context
44
import android.content.pm.PackageManager
5+
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
56
import android.os.Build
67
import co.touchlab.kermit.Logger
78
import kotlinx.coroutines.Dispatchers
89
import kotlinx.coroutines.withContext
910
import zed.rainxch.core.domain.model.ApkPackageInfo
1011
import zed.rainxch.core.domain.system.InstallerInfoExtractor
1112
import java.io.File
13+
import java.security.MessageDigest
1214

1315
class AndroidInstallerInfoExtractor(
1416
private val context: Context,
@@ -17,7 +19,11 @@ class AndroidInstallerInfoExtractor(
1719
withContext(Dispatchers.IO) {
1820
try {
1921
val packageManager = context.packageManager
20-
val flags = PackageManager.GET_META_DATA or PackageManager.GET_ACTIVITIES
22+
val flags =
23+
PackageManager.GET_META_DATA or
24+
PackageManager.GET_ACTIVITIES or
25+
GET_SIGNING_CERTIFICATES
26+
2127
val packageInfo =
2228
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
2329
packageManager.getPackageArchiveInfo(
@@ -31,9 +37,11 @@ class AndroidInstallerInfoExtractor(
3137

3238
if (packageInfo == null) {
3339
Logger.e {
34-
"Failed to parse APK at $filePath, file exists: ${File(
35-
filePath,
36-
).exists()}, size: ${File(filePath).length()}"
40+
"Failed to parse APK at $filePath, file exists: ${
41+
File(
42+
filePath,
43+
).exists()
44+
}, size: ${File(filePath).length()}"
3745
}
3846
return@withContext null
3947
}
@@ -50,12 +58,43 @@ class AndroidInstallerInfoExtractor(
5058
@Suppress("DEPRECATION")
5159
packageInfo.versionCode.toLong()
5260
}
61+
val fingerprint: String? =
62+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
63+
val sigInfo = packageInfo.signingInfo
64+
val certs =
65+
if (sigInfo?.hasMultipleSigners() == true) {
66+
sigInfo.apkContentsSigners
67+
} else {
68+
sigInfo?.signingCertificateHistory
69+
}
70+
certs?.firstOrNull()?.toByteArray()?.let { certBytes ->
71+
MessageDigest
72+
.getInstance("SHA-256")
73+
.digest(certBytes)
74+
.joinToString(":") { "%02X".format(it) }
75+
}
76+
} else {
77+
@Suppress("DEPRECATION")
78+
val legacyInfo =
79+
packageManager.getPackageArchiveInfo(
80+
filePath,
81+
PackageManager.GET_SIGNATURES,
82+
)
83+
@Suppress("DEPRECATION")
84+
legacyInfo?.signatures?.firstOrNull()?.toByteArray()?.let { certBytes ->
85+
MessageDigest
86+
.getInstance("SHA-256")
87+
.digest(certBytes)
88+
.joinToString(":") { "%02X".format(it) }
89+
}
90+
}
5391

5492
ApkPackageInfo(
93+
appName = appName,
5594
packageName = packageInfo.packageName,
5695
versionName = packageInfo.versionName ?: "unknown",
5796
versionCode = versionCode,
58-
appName = appName,
97+
signingFingerprint = fingerprint,
5998
)
6099
} catch (e: Exception) {
61100
Logger.e { "Failed to extract APK info: ${e.message}, file: $filePath" }

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

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ import co.touchlab.kermit.Logger
1818
import kotlinx.coroutines.flow.first
1919
import org.koin.core.component.KoinComponent
2020
import org.koin.core.component.inject
21+
import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager
22+
import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus
2123
import zed.rainxch.core.domain.model.InstalledApp
2224
import zed.rainxch.core.domain.model.InstallerType
2325
import zed.rainxch.core.domain.network.Downloader
2426
import zed.rainxch.core.domain.repository.InstalledAppsRepository
2527
import zed.rainxch.core.domain.repository.ThemesRepository
2628
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
2929

3030
/**
3131
* Background worker that automatically downloads and silently installs
@@ -46,21 +46,20 @@ class AutoUpdateWorker(
4646
private val themesRepository: ThemesRepository by inject()
4747
private val shizukuServiceManager: ShizukuServiceManager by inject()
4848

49-
override suspend fun doWork(): Result =
50-
try {
49+
override suspend fun doWork(): Result {
50+
return try {
5151
Logger.i { "AutoUpdateWorker: Starting auto-update" }
5252

53-
// Double-check preferences (they may have changed since scheduling)
5453
val autoUpdateEnabled = themesRepository.getAutoUpdateEnabled().first()
5554
val installerType = themesRepository.getInstallerType().first()
5655

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.
5956
shizukuServiceManager.refreshStatus()
6057
val shizukuReady = shizukuServiceManager.status.value == ShizukuStatus.READY
6158

6259
if (!autoUpdateEnabled || installerType != InstallerType.SHIZUKU || !shizukuReady) {
63-
Logger.i { "AutoUpdateWorker: Conditions not met (autoUpdate=$autoUpdateEnabled, installer=$installerType, shizuku=$shizukuReady), skipping" }
60+
Logger.i {
61+
"AutoUpdateWorker: Conditions not met (autoUpdate=$autoUpdateEnabled, installer=$installerType, shizuku=$shizukuReady), skipping"
62+
}
6463
return Result.success()
6564
}
6665

@@ -91,7 +90,6 @@ class AutoUpdateWorker(
9190
} catch (e: Exception) {
9291
failedApps.add(app.appName)
9392
Logger.e { "AutoUpdateWorker: Failed to update ${app.appName}: ${e.message}" }
94-
// Clear pending status on failure
9593
try {
9694
installedAppsRepository.updatePendingStatus(app.packageName, false)
9795
} catch (clearEx: Exception) {
@@ -100,7 +98,6 @@ class AutoUpdateWorker(
10098
}
10199
}
102100

103-
// Show summary notification
104101
showSummaryNotification(successfulApps, failedApps)
105102

106103
Logger.i { "AutoUpdateWorker: Completed. Success: ${successfulApps.size}, Failed: ${failedApps.size}" }
@@ -113,24 +110,28 @@ class AutoUpdateWorker(
113110
Result.failure()
114111
}
115112
}
113+
}
116114

117115
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}")
116+
val assetUrl =
117+
app.latestAssetUrl
118+
?: throw IllegalStateException("No asset URL for ${app.appName}")
119+
val assetName =
120+
app.latestAssetName
121+
?: throw IllegalStateException("No asset name for ${app.appName}")
122+
val latestVersion =
123+
app.latestVersion
124+
?: throw IllegalStateException("No latest version for ${app.appName}")
124125

125126
val ext = assetName.substringAfterLast('.', "").lowercase()
126127

127-
// Check if we already have a valid downloaded file
128128
val existingPath = downloader.getDownloadedFilePath(assetName)
129129
if (existingPath != null) {
130130
val file = java.io.File(existingPath)
131131
try {
132132
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(existingPath)
133-
val normalizedExisting = apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: ""
133+
val normalizedExisting =
134+
apkInfo?.versionName?.removePrefix("v")?.removePrefix("V") ?: ""
134135
val normalizedLatest = latestVersion.removePrefix("v").removePrefix("V")
135136
if (normalizedExisting != normalizedLatest) {
136137
file.delete()
@@ -142,17 +143,17 @@ class AutoUpdateWorker(
142143
}
143144
}
144145

145-
// Download the APK
146146
Logger.d { "AutoUpdateWorker: Downloading $assetName for ${app.appName}" }
147147
downloader.download(assetUrl, assetName).collect { /* consume flow to completion */ }
148148

149-
val filePath = downloader.getDownloadedFilePath(assetName)
150-
?: throw IllegalStateException("Downloaded file not found for ${app.appName}")
149+
val filePath =
150+
downloader.getDownloadedFilePath(assetName)
151+
?: throw IllegalStateException("Downloaded file not found for ${app.appName}")
151152

152-
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
153-
?: throw IllegalStateException("Failed to extract APK info for ${app.appName}")
153+
val apkInfo =
154+
installer.getApkInfoExtractor().extractPackageInfo(filePath)
155+
?: throw IllegalStateException("Failed to extract APK info for ${app.appName}")
154156

155-
// Mark as pending install
156157
val currentApp = installedAppsRepository.getAppByPackage(app.packageName)
157158
if (currentApp != null) {
158159
installedAppsRepository.updateApp(
@@ -167,7 +168,6 @@ class AutoUpdateWorker(
167168
)
168169
}
169170

170-
// Silent install via Shizuku (ShizukuInstallerWrapper handles the actual Shizuku call)
171171
Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" }
172172
try {
173173
installer.install(filePath, ext)
@@ -216,7 +216,6 @@ class AutoUpdateWorker(
216216
successfulApps: List<String>,
217217
failedApps: List<String>,
218218
) {
219-
// Check notification permission for API 33+
220219
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
221220
val granted =
222221
ContextCompat.checkSelfPermission(
@@ -228,17 +227,25 @@ class AutoUpdateWorker(
228227

229228
if (successfulApps.isEmpty() && failedApps.isEmpty()) return
230229

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-
}
230+
val title =
231+
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+
}
236236

237-
val text = when {
238-
failedApps.isEmpty() -> successfulApps.joinToString(", ")
239-
successfulApps.isEmpty() -> failedApps.joinToString(", ")
240-
else -> "Updated: ${successfulApps.joinToString(", ")}. Failed: ${failedApps.joinToString(", ")}"
241-
}
237+
val text =
238+
when {
239+
failedApps.isEmpty() -> successfulApps.joinToString(", ")
240+
241+
successfulApps.isEmpty() -> failedApps.joinToString(", ")
242+
243+
else -> "Updated: ${successfulApps.joinToString(", ")}. Failed: ${
244+
failedApps.joinToString(
245+
", ",
246+
)
247+
}"
248+
}
242249

243250
val launchIntent =
244251
applicationContext.packageManager
@@ -266,15 +273,16 @@ class AutoUpdateWorker(
266273
} else {
267274
android.R.drawable.stat_notify_error
268275
},
269-
)
270-
.setContentTitle(title)
276+
).setContentTitle(title)
271277
.setContentText(text)
272278
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
273279
.setContentIntent(pendingIntent)
274280
.setAutoCancel(true)
275281
.build()
276282

277-
NotificationManagerCompat.from(applicationContext).notify(SUMMARY_NOTIFICATION_ID, notification)
283+
NotificationManagerCompat
284+
.from(applicationContext)
285+
.notify(SUMMARY_NOTIFICATION_ID, notification)
278286
}
279287

280288
companion object {

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import zed.rainxch.core.domain.system.PackageMonitor
2323
* Uses [KoinComponent] for the no-arg constructor path (manifest-registered).
2424
* The constructor with explicit dependencies is used for dynamic registration.
2525
*/
26-
class PackageEventReceiver() : BroadcastReceiver(), KoinComponent {
26+
class PackageEventReceiver() :
27+
BroadcastReceiver(),
28+
KoinComponent {
2729
private val installedAppsRepositoryKoin: InstalledAppsRepository by inject()
2830
private val packageMonitorKoin: PackageMonitor by inject()
2931

@@ -41,11 +43,9 @@ class PackageEventReceiver() : BroadcastReceiver(), KoinComponent {
4143
this.explicitMonitor = packageMonitor
4244
}
4345

44-
private fun getRepository(): InstalledAppsRepository =
45-
explicitRepository ?: installedAppsRepositoryKoin
46+
private fun getRepository(): InstalledAppsRepository = explicitRepository ?: installedAppsRepositoryKoin
4647

47-
private fun getMonitor(): PackageMonitor =
48-
explicitMonitor ?: packageMonitorKoin
48+
private fun getMonitor(): PackageMonitor = explicitMonitor ?: packageMonitorKoin
4949

5050
override fun onReceive(
5151
context: Context?,
@@ -94,6 +94,7 @@ class PackageEventReceiver() : BroadcastReceiver(), KoinComponent {
9494
newAssetUrl = app.latestAssetUrl ?: "",
9595
newVersionName = systemInfo.versionName,
9696
newVersionCode = systemInfo.versionCode,
97+
signingFingerprint = app.signingFingerprint,
9798
)
9899
repo.updatePendingStatus(packageName, false)
99100
Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" }

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

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ object UpdateScheduler {
3737
TimeUnit.MINUTES,
3838
).build()
3939

40-
// KEEP preserves the existing schedule so reopening the app doesn't reset the timer.
41-
// The schedule is only created fresh on first install or after cancel().
4240
WorkManager
4341
.getInstance(context)
4442
.enqueueUniquePeriodicWork(
@@ -47,10 +45,6 @@ object UpdateScheduler {
4745
request = request,
4846
)
4947

50-
// Run an immediate one-time check so users get notified sooner
51-
// rather than waiting up to intervalHours for the first periodic run.
52-
// Uses REPLACE so each app launch gets a fresh check (the previous one-time
53-
// work may have already completed).
5448
val immediateRequest =
5549
OneTimeWorkRequestBuilder<UpdateCheckWorker>()
5650
.setConstraints(constraints)
@@ -68,11 +62,6 @@ object UpdateScheduler {
6862
Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h + immediate check" }
6963
}
7064

71-
/**
72-
* Force-reschedules the periodic update check with a new interval.
73-
* Uses UPDATE policy to replace the existing schedule immediately.
74-
* Call this when the user changes the update check interval in settings.
75-
*/
7665
fun reschedule(
7766
context: Context,
7867
intervalHours: Long,
@@ -105,10 +94,6 @@ object UpdateScheduler {
10594
Logger.i { "UpdateScheduler: Rescheduled periodic update check to every ${intervalHours}h" }
10695
}
10796

108-
/**
109-
* Enqueues a one-time [AutoUpdateWorker] to download and silently install
110-
* all available updates via Shizuku. Uses KEEP policy to avoid duplicate runs.
111-
*/
11297
fun scheduleAutoUpdate(context: Context) {
11398
val constraints =
11499
Constraints
@@ -123,8 +108,7 @@ object UpdateScheduler {
123108
BackoffPolicy.EXPONENTIAL,
124109
15,
125110
TimeUnit.MINUTES,
126-
)
127-
.build()
111+
).build()
128112

129113
WorkManager
130114
.getInstance(context)

0 commit comments

Comments
 (0)