Skip to content

Commit 6c443c9

Browse files
committed
refactor: decouple installation and validation logic from DetailsViewModel
- Extract APK validation, fingerprint checking, and database persistence into a new `InstallationManager` interface and implementation. - Introduce `AttestationVerifier` to handle GitHub supply-chain security verification independently. - Move version normalization and semantic comparison logic to a pure `VersionHelper` utility. - Refactor `DetailsViewModel` to utilize the new managers, significantly reducing its complexity and size. - Define sealed interfaces for `ApkValidationResult` and `FingerprintCheckResult` to handle installation edge cases more robustly. - Update Koin dependency injection modules to include the new domain and data layer components. - Standardize parameters for saving and updating installed apps using new data models (`SaveInstalledAppParams`, `UpdateInstalledAppParams`).
1 parent 610b252 commit 6c443c9

11 files changed

Lines changed: 1330 additions & 1026 deletions

File tree

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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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.system.ApkValidationResult
12+
import zed.rainxch.details.domain.system.FingerprintCheckResult
13+
import zed.rainxch.details.domain.system.InstallationManager
14+
import zed.rainxch.details.domain.system.SaveInstalledAppParams
15+
import zed.rainxch.details.domain.system.UpdateInstalledAppParams
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+
installedAppsRepository.updateAppVersion(
124+
packageName = params.apkInfo.packageName,
125+
newTag = params.releaseTag,
126+
newAssetName = params.assetName,
127+
newAssetUrl = params.assetUrl,
128+
newVersionName = params.apkInfo.versionName,
129+
newVersionCode = params.apkInfo.versionCode,
130+
signingFingerprint = params.apkInfo.signingFingerprint,
131+
)
132+
}
133+
}
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+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package zed.rainxch.details.domain.model
2+
3+
import zed.rainxch.core.domain.model.ApkPackageInfo
4+
5+
data class UpdateInstalledAppParams(
6+
val apkInfo: ApkPackageInfo,
7+
val assetName: String,
8+
val assetUrl: String,
9+
val releaseTag: String,
10+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package zed.rainxch.details.domain.system
2+
3+
/**
4+
* Verifies build attestations for downloaded assets using GitHub's
5+
* supply-chain security API.
6+
*/
7+
interface AttestationVerifier {
8+
/**
9+
* Computes the SHA-256 digest of [filePath] and checks whether
10+
* the repository [owner]/[repoName] has a matching attestation.
11+
*
12+
* @return `true` if a valid attestation exists, `false` otherwise.
13+
*/
14+
suspend fun verify(
15+
owner: String,
16+
repoName: String,
17+
filePath: String,
18+
): Boolean
19+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package zed.rainxch.details.domain.system
2+
3+
import zed.rainxch.core.domain.model.ApkPackageInfo
4+
import zed.rainxch.core.domain.model.GithubRepoSummary
5+
import zed.rainxch.core.domain.model.InstalledApp
6+
import zed.rainxch.details.domain.model.ApkValidationResult
7+
import zed.rainxch.details.domain.model.FingerprintCheckResult
8+
import zed.rainxch.details.domain.model.SaveInstalledAppParams
9+
import zed.rainxch.details.domain.model.UpdateInstalledAppParams
10+
11+
/**
12+
* Encapsulates APK validation, fingerprint checking, and
13+
* installed-app database persistence so the ViewModel stays thin.
14+
*/
15+
interface InstallationManager {
16+
/**
17+
* Extracts [ApkPackageInfo] from [filePath] and validates it.
18+
* On an update, verifies the package name matches [trackedPackageName].
19+
*/
20+
suspend fun validateApk(
21+
filePath: String,
22+
isUpdate: Boolean,
23+
trackedPackageName: String?,
24+
): ApkValidationResult
25+
26+
/**
27+
* Checks whether the signing fingerprint of [apkInfo] matches
28+
* the fingerprint previously recorded for the same package.
29+
*/
30+
suspend fun checkSigningFingerprint(apkInfo: ApkPackageInfo): FingerprintCheckResult
31+
32+
/**
33+
* Saves a freshly installed app to the database and optionally
34+
* updates the favourite install status.
35+
*
36+
* @return the reloaded [InstalledApp], or `null` on failure.
37+
*/
38+
suspend fun saveNewInstalledApp(params: SaveInstalledAppParams): InstalledApp?
39+
40+
/**
41+
* Updates the version metadata of an already-tracked app.
42+
*/
43+
suspend fun updateInstalledAppVersion(params: UpdateInstalledAppParams)
44+
}

0 commit comments

Comments
 (0)