Skip to content

Commit d8f39d9

Browse files
authored
Merge pull request #348 from OpenHub-Store/shizuku-status-fix
feat: track installation outcome to improve pending install state
2 parents 67bb54d + a482aec commit d8f39d9

15 files changed

Lines changed: 1384 additions & 1045 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import co.touchlab.kermit.Logger
1212
import zed.rainxch.core.domain.model.AssetArchitectureMatcher
1313
import zed.rainxch.core.domain.model.GithubAsset
1414
import zed.rainxch.core.domain.model.SystemArchitecture
15+
import zed.rainxch.core.domain.system.InstallOutcome
1516
import zed.rainxch.core.domain.system.Installer
1617
import zed.rainxch.core.domain.system.InstallerInfoExtractor
1718
import java.io.File
@@ -134,7 +135,7 @@ class AndroidInstaller(
134135
override suspend fun install(
135136
filePath: String,
136137
extOrMime: String,
137-
) {
138+
): InstallOutcome {
138139
val file = File(filePath)
139140
if (!file.exists()) {
140141
throw IllegalStateException("APK file not found: $filePath")
@@ -158,6 +159,8 @@ class AndroidInstaller(
158159
} else {
159160
throw IllegalStateException("No installer available on this device")
160161
}
162+
163+
return InstallOutcome.DELEGATED_TO_SYSTEM
161164
}
162165

163166
override fun uninstall(packageName: String) {

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import zed.rainxch.core.domain.model.GithubAsset
1111
import zed.rainxch.core.domain.model.InstallerType
1212
import zed.rainxch.core.domain.model.SystemArchitecture
1313
import zed.rainxch.core.domain.repository.TweaksRepository
14+
import zed.rainxch.core.domain.system.InstallOutcome
1415
import zed.rainxch.core.domain.system.Installer
1516
import zed.rainxch.core.domain.system.InstallerInfoExtractor
1617

@@ -97,7 +98,7 @@ class ShizukuInstallerWrapper(
9798
override suspend fun install(
9899
filePath: String,
99100
extOrMime: String,
100-
) {
101+
): InstallOutcome {
101102
Logger.d(TAG) { "install() called — filePath=$filePath, extOrMime=$extOrMime" }
102103
Logger.d(TAG) { "cachedInstallerType=$cachedInstallerType, shizukuStatus=${shizukuServiceManager.status.value}" }
103104

@@ -122,7 +123,7 @@ class ShizukuInstallerWrapper(
122123
Logger.d(TAG) { "Shizuku installPackage() returned: $result" }
123124
if (result == 0) {
124125
Logger.d(TAG) { "Shizuku install SUCCEEDED for: $filePath" }
125-
return
126+
return InstallOutcome.COMPLETED
126127
}
127128
Logger.w(TAG) { "Shizuku install FAILED with code: $result, falling back to standard installer" }
128129
} else {
@@ -137,7 +138,7 @@ class ShizukuInstallerWrapper(
137138

138139
Logger.d(TAG) { "Using standard AndroidInstaller for: $filePath" }
139140
androidInstaller.ensurePermissionsOrThrow(extOrMime)
140-
androidInstaller.install(filePath, extOrMime)
141+
return androidInstaller.install(filePath, extOrMime)
141142
}
142143

143144
override fun uninstall(packageName: String) {

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import zed.rainxch.core.domain.model.AssetArchitectureMatcher
99
import zed.rainxch.core.domain.model.GithubAsset
1010
import zed.rainxch.core.domain.model.Platform
1111
import zed.rainxch.core.domain.model.SystemArchitecture
12+
import zed.rainxch.core.domain.system.InstallOutcome
1213
import zed.rainxch.core.domain.system.Installer
1314
import zed.rainxch.core.domain.system.InstallerInfoExtractor
1415
import java.awt.Desktop
@@ -353,21 +354,24 @@ class DesktopInstaller(
353354
override suspend fun install(
354355
filePath: String,
355356
extOrMime: String,
356-
) = withContext(Dispatchers.IO) {
357-
val file = File(filePath)
358-
if (!file.exists()) {
359-
throw IllegalStateException("File not found: $filePath")
360-
}
357+
): InstallOutcome =
358+
withContext(Dispatchers.IO) {
359+
val file = File(filePath)
360+
if (!file.exists()) {
361+
throw IllegalStateException("File not found: $filePath")
362+
}
361363

362-
val ext = extOrMime.lowercase().removePrefix(".")
364+
val ext = extOrMime.lowercase().removePrefix(".")
363365

364-
when (platform) {
365-
Platform.WINDOWS -> installWindows(file, ext)
366-
Platform.MACOS -> installMacOS(file, ext)
367-
Platform.LINUX -> installLinux(file, ext)
368-
else -> throw UnsupportedOperationException("Installation not supported on $platform")
366+
when (platform) {
367+
Platform.WINDOWS -> installWindows(file, ext)
368+
Platform.MACOS -> installMacOS(file, ext)
369+
Platform.LINUX -> installLinux(file, ext)
370+
else -> throw UnsupportedOperationException("Installation not supported on $platform")
371+
}
372+
373+
InstallOutcome.DELEGATED_TO_SYSTEM
369374
}
370-
}
371375

372376
private fun installWindows(
373377
file: File,

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ package zed.rainxch.core.domain.system
33
import zed.rainxch.core.domain.model.GithubAsset
44
import zed.rainxch.core.domain.model.SystemArchitecture
55

6+
/**
7+
* Result of an [Installer.install] call.
8+
*/
9+
enum class InstallOutcome {
10+
/**
11+
* Installation completed synchronously (e.g. Shizuku silent install).
12+
* The package is already installed on the system — no need to wait
13+
* for a broadcast to confirm.
14+
*/
15+
COMPLETED,
16+
17+
/**
18+
* Installation was handed off to the system UI or an external process.
19+
* The caller should treat the install as pending until a
20+
* PACKAGE_ADDED / PACKAGE_REPLACED broadcast confirms it.
21+
*/
22+
DELEGATED_TO_SYSTEM,
23+
}
24+
625
interface Installer {
726
suspend fun isSupported(extOrMime: String): Boolean
827

@@ -11,7 +30,7 @@ interface Installer {
1130
suspend fun install(
1231
filePath: String,
1332
extOrMime: String,
14-
)
33+
): InstallOutcome
1534

1635
fun uninstall(packageName: String)
1736

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ package zed.rainxch.details.data.di
33
import org.koin.dsl.module
44
import zed.rainxch.details.data.repository.DetailsRepositoryImpl
55
import zed.rainxch.details.data.repository.TranslationRepositoryImpl
6+
import zed.rainxch.details.data.system.AttestationVerifierImpl
7+
import zed.rainxch.details.data.system.InstallationManagerImpl
68
import zed.rainxch.details.domain.repository.DetailsRepository
79
import zed.rainxch.details.domain.repository.TranslationRepository
10+
import zed.rainxch.details.domain.system.AttestationVerifier
11+
import zed.rainxch.details.domain.system.InstallationManager
812

913
val detailsModule =
1014
module {
@@ -22,4 +26,20 @@ val detailsModule =
2226
localizationManager = get(),
2327
)
2428
}
29+
30+
single<AttestationVerifier> {
31+
AttestationVerifierImpl(
32+
detailsRepository = get(),
33+
logger = get(),
34+
)
35+
}
36+
37+
single<InstallationManager> {
38+
InstallationManagerImpl(
39+
installer = get(),
40+
installedAppsRepository = get(),
41+
favouritesRepository = get(),
42+
logger = get(),
43+
)
44+
}
2545
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package zed.rainxch.details.data.system
2+
3+
import zed.rainxch.core.domain.logging.GitHubStoreLogger
4+
import zed.rainxch.details.domain.repository.DetailsRepository
5+
import zed.rainxch.details.domain.system.AttestationVerifier
6+
import java.io.File
7+
import java.io.FileInputStream
8+
import java.security.MessageDigest
9+
10+
class AttestationVerifierImpl(
11+
private val detailsRepository: DetailsRepository,
12+
private val logger: GitHubStoreLogger,
13+
) : AttestationVerifier {
14+
override suspend fun verify(
15+
owner: String,
16+
repoName: String,
17+
filePath: String,
18+
): Boolean =
19+
try {
20+
val digest = computeSha256(filePath)
21+
detailsRepository.checkAttestations(owner, repoName, digest)
22+
} catch (e: Exception) {
23+
logger.debug("Attestation check error: ${e.message}")
24+
false
25+
}
26+
27+
private fun computeSha256(filePath: String): String {
28+
val digest = MessageDigest.getInstance("SHA-256")
29+
val buffer = ByteArray(8192)
30+
FileInputStream(File(filePath)).use { fis ->
31+
var bytesRead: Int
32+
while (fis.read(buffer).also { bytesRead = it } != -1) {
33+
digest.update(buffer, 0, bytesRead)
34+
}
35+
}
36+
return digest.digest().joinToString("") { "%02x".format(it) }
37+
}
38+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package zed.rainxch.details.data.system
2+
3+
import kotlinx.coroutines.delay
4+
import zed.rainxch.core.domain.logging.GitHubStoreLogger
5+
import zed.rainxch.core.domain.model.ApkPackageInfo
6+
import zed.rainxch.core.domain.model.InstallSource
7+
import zed.rainxch.core.domain.model.InstalledApp
8+
import zed.rainxch.core.domain.repository.FavouritesRepository
9+
import zed.rainxch.core.domain.repository.InstalledAppsRepository
10+
import zed.rainxch.core.domain.system.Installer
11+
import zed.rainxch.details.domain.model.ApkValidationResult
12+
import zed.rainxch.details.domain.model.FingerprintCheckResult
13+
import zed.rainxch.details.domain.model.SaveInstalledAppParams
14+
import zed.rainxch.details.domain.model.UpdateInstalledAppParams
15+
import zed.rainxch.details.domain.system.InstallationManager
16+
import kotlin.time.Clock.System
17+
import kotlin.time.ExperimentalTime
18+
19+
class InstallationManagerImpl(
20+
private val installer: Installer,
21+
private val installedAppsRepository: InstalledAppsRepository,
22+
private val favouritesRepository: FavouritesRepository,
23+
private val logger: GitHubStoreLogger,
24+
) : InstallationManager {
25+
override suspend fun validateApk(
26+
filePath: String,
27+
isUpdate: Boolean,
28+
trackedPackageName: String?,
29+
): ApkValidationResult {
30+
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
31+
?: return ApkValidationResult.ExtractionFailed
32+
33+
if (isUpdate && trackedPackageName != null && apkInfo.packageName != trackedPackageName) {
34+
return ApkValidationResult.PackageMismatch(
35+
apkPackageName = apkInfo.packageName,
36+
installedPackageName = trackedPackageName,
37+
)
38+
}
39+
40+
return ApkValidationResult.Valid(apkInfo)
41+
}
42+
43+
override suspend fun checkSigningFingerprint(apkInfo: ApkPackageInfo): FingerprintCheckResult {
44+
val existingApp =
45+
installedAppsRepository.getAppByPackage(apkInfo.packageName)
46+
?: return FingerprintCheckResult.Ok
47+
48+
val expectedFp = existingApp.signingFingerprint ?: return FingerprintCheckResult.Ok
49+
val actualFp = apkInfo.signingFingerprint ?: return FingerprintCheckResult.Ok
50+
51+
return if (expectedFp == actualFp) {
52+
FingerprintCheckResult.Ok
53+
} else {
54+
FingerprintCheckResult.Mismatch(
55+
expectedFingerprint = expectedFp,
56+
actualFingerprint = actualFp,
57+
)
58+
}
59+
}
60+
61+
@OptIn(ExperimentalTime::class)
62+
override suspend fun saveNewInstalledApp(params: SaveInstalledAppParams): InstalledApp? =
63+
try {
64+
val apkInfo = params.apkInfo
65+
val repo = params.repo
66+
67+
val installedApp =
68+
InstalledApp(
69+
packageName = apkInfo.packageName,
70+
repoId = repo.id,
71+
repoName = repo.name,
72+
repoOwner = repo.owner.login,
73+
repoOwnerAvatarUrl = repo.owner.avatarUrl,
74+
repoDescription = repo.description,
75+
primaryLanguage = repo.language,
76+
repoUrl = repo.htmlUrl,
77+
installedVersion = params.releaseTag,
78+
installedAssetName = params.assetName,
79+
installedAssetUrl = params.assetUrl,
80+
latestVersion = params.releaseTag,
81+
latestAssetName = params.assetName,
82+
latestAssetUrl = params.assetUrl,
83+
latestAssetSize = params.assetSize,
84+
appName = apkInfo.appName,
85+
installSource = InstallSource.THIS_APP,
86+
installedAt = System.now().toEpochMilliseconds(),
87+
lastCheckedAt = System.now().toEpochMilliseconds(),
88+
lastUpdatedAt = System.now().toEpochMilliseconds(),
89+
isUpdateAvailable = false,
90+
updateCheckEnabled = true,
91+
releaseNotes = "",
92+
systemArchitecture = installer.detectSystemArchitecture().name,
93+
fileExtension = params.assetName.substringAfterLast('.', ""),
94+
isPendingInstall = params.isPendingInstall,
95+
installedVersionName = apkInfo.versionName,
96+
installedVersionCode = apkInfo.versionCode,
97+
latestVersionName = apkInfo.versionName,
98+
latestVersionCode = apkInfo.versionCode,
99+
signingFingerprint = apkInfo.signingFingerprint,
100+
)
101+
102+
installedAppsRepository.saveInstalledApp(installedApp)
103+
104+
if (params.isFavourite) {
105+
favouritesRepository.updateFavoriteInstallStatus(
106+
repoId = repo.id,
107+
installed = true,
108+
packageName = apkInfo.packageName,
109+
)
110+
}
111+
112+
delay(1000)
113+
val reloaded = installedAppsRepository.getAppByPackage(apkInfo.packageName)
114+
logger.debug("Successfully saved and reloaded app: ${reloaded?.packageName}")
115+
reloaded
116+
} catch (t: Throwable) {
117+
logger.error("Failed to save installed app to database: ${t.message}")
118+
t.printStackTrace()
119+
null
120+
}
121+
122+
override suspend fun updateInstalledAppVersion(params: UpdateInstalledAppParams) {
123+
val packageName = params.apkInfo.packageName
124+
installedAppsRepository.updateAppVersion(
125+
packageName = packageName,
126+
newTag = params.releaseTag,
127+
newAssetName = params.assetName,
128+
newAssetUrl = params.assetUrl,
129+
newVersionName = params.apkInfo.versionName,
130+
newVersionCode = params.apkInfo.versionCode,
131+
signingFingerprint = params.apkInfo.signingFingerprint,
132+
)
133+
installedAppsRepository.updatePendingStatus(packageName, params.isPendingInstall)
134+
}
135+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package zed.rainxch.details.domain.model
2+
3+
import zed.rainxch.core.domain.model.ApkPackageInfo
4+
5+
sealed interface ApkValidationResult {
6+
/** APK is valid and ready to install. */
7+
data class Valid(
8+
val apkInfo: ApkPackageInfo,
9+
) : ApkValidationResult
10+
11+
/** Could not extract package information from the APK. */
12+
data object ExtractionFailed : ApkValidationResult
13+
14+
/** Package name in the APK does not match the currently installed app. */
15+
data class PackageMismatch(
16+
val apkPackageName: String,
17+
val installedPackageName: String,
18+
) : ApkValidationResult
19+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package zed.rainxch.details.domain.model
2+
3+
sealed interface FingerprintCheckResult {
4+
/** Fingerprint matches or no prior fingerprint is recorded. */
5+
data object Ok : FingerprintCheckResult
6+
7+
/** Signing key has changed compared to the previously installed version. */
8+
data class Mismatch(
9+
val expectedFingerprint: String,
10+
val actualFingerprint: String,
11+
) : FingerprintCheckResult
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package zed.rainxch.details.domain.model
2+
3+
import zed.rainxch.core.domain.model.ApkPackageInfo
4+
import zed.rainxch.core.domain.model.GithubRepoSummary
5+
6+
data class SaveInstalledAppParams(
7+
val repo: GithubRepoSummary,
8+
val apkInfo: ApkPackageInfo,
9+
val assetName: String,
10+
val assetUrl: String,
11+
val assetSize: Long,
12+
val releaseTag: String,
13+
val isPendingInstall: Boolean,
14+
val isFavourite: Boolean,
15+
)

0 commit comments

Comments
 (0)