From 6d309d57b59e3877c9055fcc7a0da55f462883b7 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 21 Mar 2026 14:17:43 +0500 Subject: [PATCH 1/3] feat: track installation outcome to improve pending install state - Introduce `InstallOutcome` enum to distinguish between synchronous (`COMPLETED`) and asynchronous (`DELEGATED_TO_SYSTEM`) installation processes. - Update `Installer.install` signature to return `InstallOutcome`. - Implement `InstallOutcome.COMPLETED` in `ShizukuInstallerWrapper` for successful silent installs. - Implement `InstallOutcome.DELEGATED_TO_SYSTEM` for standard Android and Desktop installers where the OS handles the UI. - Update `DetailsViewModel` to use the installation outcome to more accurately set the `isPendingInstall` flag. - Refactor internal install logic in `DetailsViewModel` to propagate the outcome to repository sync operations. --- .../core/data/services/AndroidInstaller.kt | 5 +++- .../shizuku/ShizukuInstallerWrapper.kt | 7 +++-- .../core/data/services/DesktopInstaller.kt | 28 +++++++++++-------- .../rainxch/core/domain/system/Installer.kt | 21 +++++++++++++- .../details/presentation/DetailsViewModel.kt | 7 +++-- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt index 935ef16d..c18c6142 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt @@ -12,6 +12,7 @@ import co.touchlab.kermit.Logger import zed.rainxch.core.domain.model.AssetArchitectureMatcher import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.SystemArchitecture +import zed.rainxch.core.domain.system.InstallOutcome import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerInfoExtractor import java.io.File @@ -134,7 +135,7 @@ class AndroidInstaller( override suspend fun install( filePath: String, extOrMime: String, - ) { + ): InstallOutcome { val file = File(filePath) if (!file.exists()) { throw IllegalStateException("APK file not found: $filePath") @@ -158,6 +159,8 @@ class AndroidInstaller( } else { throw IllegalStateException("No installer available on this device") } + + return InstallOutcome.DELEGATED_TO_SYSTEM } override fun uninstall(packageName: String) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt index 3a8ca1a9..d2b20ea3 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt @@ -11,6 +11,7 @@ import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.SystemArchitecture import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.system.InstallOutcome import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerInfoExtractor @@ -97,7 +98,7 @@ class ShizukuInstallerWrapper( override suspend fun install( filePath: String, extOrMime: String, - ) { + ): InstallOutcome { Logger.d(TAG) { "install() called — filePath=$filePath, extOrMime=$extOrMime" } Logger.d(TAG) { "cachedInstallerType=$cachedInstallerType, shizukuStatus=${shizukuServiceManager.status.value}" } @@ -122,7 +123,7 @@ class ShizukuInstallerWrapper( Logger.d(TAG) { "Shizuku installPackage() returned: $result" } if (result == 0) { Logger.d(TAG) { "Shizuku install SUCCEEDED for: $filePath" } - return + return InstallOutcome.COMPLETED } Logger.w(TAG) { "Shizuku install FAILED with code: $result, falling back to standard installer" } } else { @@ -137,7 +138,7 @@ class ShizukuInstallerWrapper( Logger.d(TAG) { "Using standard AndroidInstaller for: $filePath" } androidInstaller.ensurePermissionsOrThrow(extOrMime) - androidInstaller.install(filePath, extOrMime) + return androidInstaller.install(filePath, extOrMime) } override fun uninstall(packageName: String) { diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt index bee5acae..09c999fd 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt @@ -9,6 +9,7 @@ import zed.rainxch.core.domain.model.AssetArchitectureMatcher import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.SystemArchitecture +import zed.rainxch.core.domain.system.InstallOutcome import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.InstallerInfoExtractor import java.awt.Desktop @@ -353,21 +354,24 @@ class DesktopInstaller( override suspend fun install( filePath: String, extOrMime: String, - ) = withContext(Dispatchers.IO) { - val file = File(filePath) - if (!file.exists()) { - throw IllegalStateException("File not found: $filePath") - } + ): InstallOutcome = + withContext(Dispatchers.IO) { + val file = File(filePath) + if (!file.exists()) { + throw IllegalStateException("File not found: $filePath") + } - val ext = extOrMime.lowercase().removePrefix(".") + val ext = extOrMime.lowercase().removePrefix(".") - when (platform) { - Platform.WINDOWS -> installWindows(file, ext) - Platform.MACOS -> installMacOS(file, ext) - Platform.LINUX -> installLinux(file, ext) - else -> throw UnsupportedOperationException("Installation not supported on $platform") + when (platform) { + Platform.WINDOWS -> installWindows(file, ext) + Platform.MACOS -> installMacOS(file, ext) + Platform.LINUX -> installLinux(file, ext) + else -> throw UnsupportedOperationException("Installation not supported on $platform") + } + + InstallOutcome.DELEGATED_TO_SYSTEM } - } private fun installWindows( file: File, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt index 2de9bc08..9ab0a99e 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt @@ -3,6 +3,25 @@ package zed.rainxch.core.domain.system import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.SystemArchitecture +/** + * Result of an [Installer.install] call. + */ +enum class InstallOutcome { + /** + * Installation completed synchronously (e.g. Shizuku silent install). + * The package is already installed on the system — no need to wait + * for a broadcast to confirm. + */ + COMPLETED, + + /** + * Installation was handed off to the system UI or an external process. + * The caller should treat the install as pending until a + * PACKAGE_ADDED / PACKAGE_REPLACED broadcast confirms it. + */ + DELEGATED_TO_SYSTEM, +} + interface Installer { suspend fun isSupported(extOrMime: String): Boolean @@ -11,7 +30,7 @@ interface Installer { suspend fun install( filePath: String, extOrMime: String, - ) + ): InstallOutcome fun uninstall(packageName: String) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 188cd270..3d167aa9 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -36,6 +36,7 @@ import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.system.InstallOutcome import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase @@ -1192,7 +1193,7 @@ class DetailsViewModel( } } - installer.install(filePath, ext) + val installOutcome = installer.install(filePath, ext) // Launch attestation check asynchronously (non-blocking) launchAttestationCheck(filePath) @@ -1205,6 +1206,7 @@ class DetailsViewModel( releaseTag = releaseTag, isUpdate = isUpdate, filePath = filePath, + installOutcome = installOutcome, ) } else { viewModelScope.launch { @@ -1394,6 +1396,7 @@ class DetailsViewModel( releaseTag: String, isUpdate: Boolean, filePath: String, + installOutcome: InstallOutcome = InstallOutcome.DELEGATED_TO_SYSTEM, ) { try { val repo = _state.value.repository ?: return @@ -1455,7 +1458,7 @@ class DetailsViewModel( releaseNotes = "", systemArchitecture = installer.detectSystemArchitecture().name, fileExtension = assetName.substringAfterLast('.', ""), - isPendingInstall = true, + isPendingInstall = installOutcome != InstallOutcome.COMPLETED, installedVersionName = apkInfo.versionName, installedVersionCode = apkInfo.versionCode, latestVersionName = apkInfo.versionName, From 6c443c915b67960917d35d23045b5b930bd2d56a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 21 Mar 2026 14:52:33 +0500 Subject: [PATCH 2/3] 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`). --- .../rainxch/details/data/di/SharedModule.kt | 20 + .../data/system/AttestationVerifierImpl.kt | 38 + .../data/system/InstallationManagerImpl.kt | 133 ++ .../domain/model/ApkValidationResult.kt | 19 + .../domain/model/FingerprintCheckResult.kt | 12 + .../domain/model/SaveInstalledAppParams.kt | 15 + .../domain/model/UpdateInstalledAppParams.kt | 10 + .../domain/system/AttestationVerifier.kt | 19 + .../domain/system/InstallationManager.kt | 44 + .../details/domain/util/VersionHelper.kt | 73 + .../details/presentation/DetailsViewModel.kt | 1973 ++++++++--------- 11 files changed, 1330 insertions(+), 1026 deletions(-) create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/AttestationVerifierImpl.kt create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt index 4603e1b0..c2f6fa04 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt @@ -3,8 +3,12 @@ package zed.rainxch.details.data.di import org.koin.dsl.module import zed.rainxch.details.data.repository.DetailsRepositoryImpl import zed.rainxch.details.data.repository.TranslationRepositoryImpl +import zed.rainxch.details.data.system.AttestationVerifierImpl +import zed.rainxch.details.data.system.InstallationManagerImpl import zed.rainxch.details.domain.repository.DetailsRepository import zed.rainxch.details.domain.repository.TranslationRepository +import zed.rainxch.details.domain.system.AttestationVerifier +import zed.rainxch.details.domain.system.InstallationManager val detailsModule = module { @@ -22,4 +26,20 @@ val detailsModule = localizationManager = get(), ) } + + single { + AttestationVerifierImpl( + detailsRepository = get(), + logger = get(), + ) + } + + single { + InstallationManagerImpl( + installer = get(), + installedAppsRepository = get(), + favouritesRepository = get(), + logger = get(), + ) + } } diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/AttestationVerifierImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/AttestationVerifierImpl.kt new file mode 100644 index 00000000..019dac87 --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/AttestationVerifierImpl.kt @@ -0,0 +1,38 @@ +package zed.rainxch.details.data.system + +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.details.domain.repository.DetailsRepository +import zed.rainxch.details.domain.system.AttestationVerifier +import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest + +class AttestationVerifierImpl( + private val detailsRepository: DetailsRepository, + private val logger: GitHubStoreLogger, +) : AttestationVerifier { + override suspend fun verify( + owner: String, + repoName: String, + filePath: String, + ): Boolean = + try { + val digest = computeSha256(filePath) + detailsRepository.checkAttestations(owner, repoName, digest) + } catch (e: Exception) { + logger.debug("Attestation check error: ${e.message}") + false + } + + private fun computeSha256(filePath: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + FileInputStream(File(filePath)).use { fis -> + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt new file mode 100644 index 00000000..6cbddca4 --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt @@ -0,0 +1,133 @@ +package zed.rainxch.details.data.system + +import kotlinx.coroutines.delay +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.ApkPackageInfo +import zed.rainxch.core.domain.model.InstallSource +import zed.rainxch.core.domain.model.InstalledApp +import zed.rainxch.core.domain.repository.FavouritesRepository +import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.system.Installer +import zed.rainxch.details.domain.system.ApkValidationResult +import zed.rainxch.details.domain.system.FingerprintCheckResult +import zed.rainxch.details.domain.system.InstallationManager +import zed.rainxch.details.domain.system.SaveInstalledAppParams +import zed.rainxch.details.domain.system.UpdateInstalledAppParams +import kotlin.time.Clock.System +import kotlin.time.ExperimentalTime + +class InstallationManagerImpl( + private val installer: Installer, + private val installedAppsRepository: InstalledAppsRepository, + private val favouritesRepository: FavouritesRepository, + private val logger: GitHubStoreLogger, +) : InstallationManager { + override suspend fun validateApk( + filePath: String, + isUpdate: Boolean, + trackedPackageName: String?, + ): ApkValidationResult { + val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) + ?: return ApkValidationResult.ExtractionFailed + + if (isUpdate && trackedPackageName != null && apkInfo.packageName != trackedPackageName) { + return ApkValidationResult.PackageMismatch( + apkPackageName = apkInfo.packageName, + installedPackageName = trackedPackageName, + ) + } + + return ApkValidationResult.Valid(apkInfo) + } + + override suspend fun checkSigningFingerprint(apkInfo: ApkPackageInfo): FingerprintCheckResult { + val existingApp = + installedAppsRepository.getAppByPackage(apkInfo.packageName) + ?: return FingerprintCheckResult.Ok + + val expectedFp = existingApp.signingFingerprint ?: return FingerprintCheckResult.Ok + val actualFp = apkInfo.signingFingerprint ?: return FingerprintCheckResult.Ok + + return if (expectedFp == actualFp) { + FingerprintCheckResult.Ok + } else { + FingerprintCheckResult.Mismatch( + expectedFingerprint = expectedFp, + actualFingerprint = actualFp, + ) + } + } + + @OptIn(ExperimentalTime::class) + override suspend fun saveNewInstalledApp(params: SaveInstalledAppParams): InstalledApp? = + try { + val apkInfo = params.apkInfo + val repo = params.repo + + val installedApp = + InstalledApp( + packageName = apkInfo.packageName, + repoId = repo.id, + repoName = repo.name, + repoOwner = repo.owner.login, + repoOwnerAvatarUrl = repo.owner.avatarUrl, + repoDescription = repo.description, + primaryLanguage = repo.language, + repoUrl = repo.htmlUrl, + installedVersion = params.releaseTag, + installedAssetName = params.assetName, + installedAssetUrl = params.assetUrl, + latestVersion = params.releaseTag, + latestAssetName = params.assetName, + latestAssetUrl = params.assetUrl, + latestAssetSize = params.assetSize, + appName = apkInfo.appName, + installSource = InstallSource.THIS_APP, + installedAt = System.now().toEpochMilliseconds(), + lastCheckedAt = System.now().toEpochMilliseconds(), + lastUpdatedAt = System.now().toEpochMilliseconds(), + isUpdateAvailable = false, + updateCheckEnabled = true, + releaseNotes = "", + systemArchitecture = installer.detectSystemArchitecture().name, + fileExtension = params.assetName.substringAfterLast('.', ""), + isPendingInstall = params.isPendingInstall, + installedVersionName = apkInfo.versionName, + installedVersionCode = apkInfo.versionCode, + latestVersionName = apkInfo.versionName, + latestVersionCode = apkInfo.versionCode, + signingFingerprint = apkInfo.signingFingerprint, + ) + + installedAppsRepository.saveInstalledApp(installedApp) + + if (params.isFavourite) { + favouritesRepository.updateFavoriteInstallStatus( + repoId = repo.id, + installed = true, + packageName = apkInfo.packageName, + ) + } + + delay(1000) + val reloaded = installedAppsRepository.getAppByPackage(apkInfo.packageName) + logger.debug("Successfully saved and reloaded app: ${reloaded?.packageName}") + reloaded + } catch (t: Throwable) { + logger.error("Failed to save installed app to database: ${t.message}") + t.printStackTrace() + null + } + + override suspend fun updateInstalledAppVersion(params: UpdateInstalledAppParams) { + installedAppsRepository.updateAppVersion( + packageName = params.apkInfo.packageName, + newTag = params.releaseTag, + newAssetName = params.assetName, + newAssetUrl = params.assetUrl, + newVersionName = params.apkInfo.versionName, + newVersionCode = params.apkInfo.versionCode, + signingFingerprint = params.apkInfo.signingFingerprint, + ) + } +} diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt new file mode 100644 index 00000000..05022cb9 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt @@ -0,0 +1,19 @@ +package zed.rainxch.details.domain.model + +import zed.rainxch.core.domain.model.ApkPackageInfo + +sealed interface ApkValidationResult { + /** APK is valid and ready to install. */ + data class Valid( + val apkInfo: ApkPackageInfo, + ) : ApkValidationResult + + /** Could not extract package information from the APK. */ + data object ExtractionFailed : ApkValidationResult + + /** Package name in the APK does not match the currently installed app. */ + data class PackageMismatch( + val apkPackageName: String, + val installedPackageName: String, + ) : ApkValidationResult +} diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt new file mode 100644 index 00000000..6f471620 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt @@ -0,0 +1,12 @@ +package zed.rainxch.details.domain.model + +sealed interface FingerprintCheckResult { + /** Fingerprint matches or no prior fingerprint is recorded. */ + data object Ok : FingerprintCheckResult + + /** Signing key has changed compared to the previously installed version. */ + data class Mismatch( + val expectedFingerprint: String, + val actualFingerprint: String, + ) : FingerprintCheckResult +} diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt new file mode 100644 index 00000000..10e153a3 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt @@ -0,0 +1,15 @@ +package zed.rainxch.details.domain.model + +import zed.rainxch.core.domain.model.ApkPackageInfo +import zed.rainxch.core.domain.model.GithubRepoSummary + +data class SaveInstalledAppParams( + val repo: GithubRepoSummary, + val apkInfo: ApkPackageInfo, + val assetName: String, + val assetUrl: String, + val assetSize: Long, + val releaseTag: String, + val isPendingInstall: Boolean, + val isFavourite: Boolean, +) diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt new file mode 100644 index 00000000..2c814971 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt @@ -0,0 +1,10 @@ +package zed.rainxch.details.domain.model + +import zed.rainxch.core.domain.model.ApkPackageInfo + +data class UpdateInstalledAppParams( + val apkInfo: ApkPackageInfo, + val assetName: String, + val assetUrl: String, + val releaseTag: String, +) diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt new file mode 100644 index 00000000..06a60120 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt @@ -0,0 +1,19 @@ +package zed.rainxch.details.domain.system + +/** + * Verifies build attestations for downloaded assets using GitHub's + * supply-chain security API. + */ +interface AttestationVerifier { + /** + * Computes the SHA-256 digest of [filePath] and checks whether + * the repository [owner]/[repoName] has a matching attestation. + * + * @return `true` if a valid attestation exists, `false` otherwise. + */ + suspend fun verify( + owner: String, + repoName: String, + filePath: String, + ): Boolean +} diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt new file mode 100644 index 00000000..d1e1e243 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt @@ -0,0 +1,44 @@ +package zed.rainxch.details.domain.system + +import zed.rainxch.core.domain.model.ApkPackageInfo +import zed.rainxch.core.domain.model.GithubRepoSummary +import zed.rainxch.core.domain.model.InstalledApp +import zed.rainxch.details.domain.model.ApkValidationResult +import zed.rainxch.details.domain.model.FingerprintCheckResult +import zed.rainxch.details.domain.model.SaveInstalledAppParams +import zed.rainxch.details.domain.model.UpdateInstalledAppParams + +/** + * Encapsulates APK validation, fingerprint checking, and + * installed-app database persistence so the ViewModel stays thin. + */ +interface InstallationManager { + /** + * Extracts [ApkPackageInfo] from [filePath] and validates it. + * On an update, verifies the package name matches [trackedPackageName]. + */ + suspend fun validateApk( + filePath: String, + isUpdate: Boolean, + trackedPackageName: String?, + ): ApkValidationResult + + /** + * Checks whether the signing fingerprint of [apkInfo] matches + * the fingerprint previously recorded for the same package. + */ + suspend fun checkSigningFingerprint(apkInfo: ApkPackageInfo): FingerprintCheckResult + + /** + * Saves a freshly installed app to the database and optionally + * updates the favourite install status. + * + * @return the reloaded [InstalledApp], or `null` on failure. + */ + suspend fun saveNewInstalledApp(params: SaveInstalledAppParams): InstalledApp? + + /** + * Updates the version metadata of an already-tracked app. + */ + suspend fun updateInstalledAppVersion(params: UpdateInstalledAppParams) +} diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt new file mode 100644 index 00000000..3090b526 --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt @@ -0,0 +1,73 @@ +package zed.rainxch.details.domain.util + +import zed.rainxch.core.domain.model.GithubRelease + +/** + * Pure utility for normalising and comparing release version strings. + */ +object VersionHelper { + fun normalizeVersion(version: String?): String = + version + ?.removePrefix("v") + ?.removePrefix("V") + ?.trim() + .orEmpty() + + /** + * Returns `true` if [candidate] is strictly older than [current]. + * Uses list-index order as the primary heuristic (releases are newest-first), + * and falls back to semantic version comparison when list lookup fails. + */ + fun isDowngradeVersion( + candidate: String, + current: String, + allReleases: List, + ): Boolean { + val normalizedCandidate = normalizeVersion(candidate) + val normalizedCurrent = normalizeVersion(current) + + if (normalizedCandidate == normalizedCurrent) return false + + val candidateIndex = + allReleases.indexOfFirst { + normalizeVersion(it.tagName) == normalizedCandidate + } + val currentIndex = + allReleases.indexOfFirst { + normalizeVersion(it.tagName) == normalizedCurrent + } + + if (candidateIndex != -1 && currentIndex != -1) { + return candidateIndex > currentIndex + } + + return compareSemanticVersions(normalizedCandidate, normalizedCurrent) < 0 + } + + /** + * Compares two semantic version strings. + * Returns positive if [a] > [b], negative if [a] < [b], 0 if equal. + */ + fun compareSemanticVersions( + a: String, + b: String, + ): Int { + val aCore = a.split("-", limit = 2) + val bCore = b.split("-", limit = 2) + val aParts = aCore[0].split(".") + val bParts = bCore[0].split(".") + + val maxLen = maxOf(aParts.size, bParts.size) + for (i in 0 until maxLen) { + val aPart = aParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L + val bPart = bParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L + if (aPart != bPart) return aPart.compareTo(bPart) + } + + val aHasPre = aCore.size > 1 + val bHasPre = bCore.size > 1 + if (aHasPre != bHasPre) return if (aHasPre) -1 else 1 + + return 0 + } +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 3d167aa9..f2dbf51d 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -6,10 +6,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -22,12 +22,9 @@ import kotlinx.datetime.format.char import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger -import zed.rainxch.core.domain.model.ApkPackageInfo import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease -import zed.rainxch.core.domain.model.InstallSource -import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.network.Downloader @@ -45,6 +42,13 @@ import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.repository.DetailsRepository import zed.rainxch.details.domain.repository.TranslationRepository +import zed.rainxch.details.domain.system.ApkValidationResult +import zed.rainxch.details.domain.system.AttestationVerifier +import zed.rainxch.details.domain.system.FingerprintCheckResult +import zed.rainxch.details.domain.system.InstallationManager +import zed.rainxch.details.domain.system.SaveInstalledAppParams +import zed.rainxch.details.domain.system.UpdateInstalledAppParams +import zed.rainxch.details.domain.util.VersionHelper import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.DowngradeWarning import zed.rainxch.details.presentation.model.DownloadStage @@ -66,8 +70,6 @@ import zed.rainxch.githubstore.core.presentation.res.removed_from_favourites import zed.rainxch.githubstore.core.presentation.res.translation_failed import zed.rainxch.githubstore.core.presentation.res.update_package_mismatch import java.io.File -import java.io.FileInputStream -import java.security.MessageDigest import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock.System @@ -93,6 +95,8 @@ class DetailsViewModel( private val isComingFromUpdate: Boolean, private val tweaksRepository: TweaksRepository, private val seenReposRepository: SeenReposRepository, + private val installationManager: InstallationManager, + private val attestationVerifier: AttestationVerifier, ) : ViewModel() { private var hasLoadedInitialData = false private var currentDownloadJob: Job? = null @@ -123,251 +127,24 @@ class DetailsViewModel( private val rateLimited = AtomicBoolean(false) - private fun observeLiquidGlassEnabled() { - viewModelScope.launch { - tweaksRepository.getLiquidGlassEnabled().collect { enabled -> - _state.update { - it.copy(isLiquidGlassEnabled = enabled) - } - } - } - } - - private fun recomputeAssetsForRelease(release: GithubRelease?): Pair, GithubAsset?> { - val installable = - release - ?.assets - ?.filter { asset -> - installer.isAssetInstallable(asset.name) - }.orEmpty() - val primary = installer.choosePrimaryAsset(installable) - return installable to primary - } - - @OptIn(ExperimentalTime::class) - private fun loadInitial() { + fun confirmUninstall() { + _state.update { it.copy(showUninstallConfirmation = false) } + val installedApp = _state.value.installedApp ?: return + logger.debug("Uninstalling app (confirmed): ${installedApp.packageName}") viewModelScope.launch { try { - rateLimited.set(false) - - _state.value = _state.value.copy(isLoading = true, errorMessage = null) - - val syncResult = syncInstalledAppsUseCase() - if (syncResult.isFailure) { - logger.warn("Sync had issues but continuing: ${syncResult.exceptionOrNull()?.message}") - } - - val repo = - if (ownerParam.isNotEmpty() && repoParam.isNotEmpty()) { - detailsRepository.getRepositoryByOwnerAndName(ownerParam, repoParam) - } else { - detailsRepository.getRepositoryById(repositoryId) - } - launch { seenReposRepository.markAsSeen(repo.id) } - - val isFavoriteDeferred = - async { - try { - favouritesRepository.isFavoriteSync(repo.id) - } catch (_: RateLimitException) { - rateLimited.set(true) - null - } catch (t: Throwable) { - logger.error("Failed to load if repo is favourite: ${t.localizedMessage}") - false - } - } - val isFavorite = isFavoriteDeferred.await() - val isStarredDeferred = - async { - try { - starredRepository.isStarred(repo.id) - } catch (_: RateLimitException) { - rateLimited.set(true) - null - } catch (t: Throwable) { - logger.error("Failed to load if repo is starred: ${t.localizedMessage}") - false - } - } - val isStarred = isStarredDeferred.await() - - val owner = repo.owner.login - val name = repo.name - - _state.value = - _state.value.copy( - repository = repo, - isFavourite = isFavorite == true, - isStarred = isStarred == true, - ) - - val allReleasesDeferred = - async { - try { - detailsRepository.getAllReleases( - owner = owner, - repo = name, - defaultBranch = repo.defaultBranch, - ) - } catch (_: RateLimitException) { - rateLimited.set(true) - emptyList() - } catch (t: Throwable) { - logger.warn("Failed to load releases: ${t.message}") - emptyList() - } - } - - val statsDeferred = - async { - try { - detailsRepository.getRepoStats(owner, name) - } catch (_: RateLimitException) { - rateLimited.set(true) - null - } catch (_: Throwable) { - null - } - } - - val readmeDeferred = - async { - try { - detailsRepository.getReadme( - owner = owner, - repo = name, - defaultBranch = repo.defaultBranch, - ) - } catch (_: RateLimitException) { - rateLimited.set(true) - null - } catch (_: Throwable) { - null - } - } - - val userProfileDeferred = - async { - try { - detailsRepository.getUserProfile(owner) - } catch (_: RateLimitException) { - rateLimited.set(true) - null - } catch (t: Throwable) { - logger.warn("Failed to load user profile: ${t.message}") - null - } - } - - val installedAppDeferred = - async { - try { - val dbApp = installedAppsRepository.getAppByRepoId(repo.id) - - if (dbApp != null) { - if (dbApp.isPendingInstall && - packageMonitor.isPackageInstalled(dbApp.packageName) - ) { - installedAppsRepository.updatePendingStatus( - dbApp.packageName, - false, - ) - installedAppsRepository.getAppByPackage(dbApp.packageName) - } else { - dbApp - } - } else { - null - } - } catch (_: RateLimitException) { - rateLimited.set(true) - null - } catch (t: Throwable) { - logger.error("Failed to load installed app: ${t.message}") - null - } - } - - val isObtainiumEnabled = platform == Platform.ANDROID - val isAppManagerEnabled = platform == Platform.ANDROID - - val allReleases = allReleasesDeferred.await() - val stats = statsDeferred.await() - val readme = readmeDeferred.await() - val userProfile = userProfileDeferred.await() - val installedApp = installedAppDeferred.await() - - if (rateLimited.get()) { - _state.value = _state.value.copy(isLoading = false, errorMessage = null) - return@launch - } - - val selectedRelease = - allReleases.firstOrNull { !it.isPrerelease } - ?: allReleases.firstOrNull() - - val (installable, primary) = recomputeAssetsForRelease(selectedRelease) - - val isObtainiumAvailable = installer.isObtainiumInstalled() - val isAppManagerAvailable = installer.isAppManagerInstalled() - - logger.debug("Loaded repo: ${repo.name}, installedApp: ${installedApp?.packageName}") - - _state.value = - _state.value.copy( - isLoading = false, - errorMessage = null, - repository = repo, - allReleases = allReleases, - selectedRelease = selectedRelease, - selectedReleaseCategory = ReleaseCategory.STABLE, - stats = stats, - readmeMarkdown = readme?.first, - readmeLanguage = readme?.second, - installableAssets = installable, - primaryAsset = primary, - userProfile = userProfile, - systemArchitecture = installer.detectSystemArchitecture(), - isObtainiumAvailable = isObtainiumAvailable, - isObtainiumEnabled = isObtainiumEnabled, - isAppManagerAvailable = isAppManagerAvailable, - isAppManagerEnabled = isAppManagerEnabled, - installedApp = installedApp, - deviceLanguageCode = translationRepository.getDeviceLanguageCode(), - isComingFromUpdate = isComingFromUpdate, - ) - - observeInstalledApp(repo.id) - } catch (e: RateLimitException) { - logger.error("Rate limited: ${e.message}") - _state.value = - _state.value.copy( - isLoading = false, - errorMessage = getString(Res.string.rate_limit_exceeded), - ) - } catch (t: Throwable) { - logger.error("Details load failed: ${t.message}") - _state.value = - _state.value.copy( - isLoading = false, - errorMessage = t.message ?: "Failed to load details", - ) + installer.uninstall(installedApp.packageName) + } catch (e: Exception) { + logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") + _events.send( + DetailsEvent.OnMessage( + getString(Res.string.failed_to_uninstall, installedApp.packageName), + ), + ) } } } - private fun observeInstalledApp(repoId: Long) { - viewModelScope.launch { - installedAppsRepository - .getAppByRepoIdAsFlow(repoId) - .distinctUntilChanged() - .collect { app -> - _state.update { it.copy(installedApp = app) } - } - } - } - @OptIn(ExperimentalTime::class) fun onAction(action: DetailsAction) { when (action) { @@ -377,11 +154,7 @@ class DetailsViewModel( } DetailsAction.OnDismissDowngradeWarning -> { - _state.update { - it.copy( - downgradeWarning = null, - ) - } + dismissDowngradeWarning() } DetailsAction.OnDismissSigningKeyWarning -> { @@ -395,84 +168,11 @@ class DetailsViewModel( } DetailsAction.OnOverrideSigningKeyWarning -> { - val warning = _state.value.signingKeyWarning ?: return - _state.update { it.copy(signingKeyWarning = null) } - viewModelScope.launch { - try { - val ext = warning.pendingAssetName.substringAfterLast('.', "").lowercase() - installer.install(warning.pendingFilePath, ext) - - if (platform == Platform.ANDROID) { - saveInstalledAppToDatabase( - assetName = warning.pendingAssetName, - assetUrl = warning.pendingDownloadUrl, - assetSize = warning.pendingSizeBytes, - releaseTag = warning.pendingReleaseTag, - isUpdate = warning.pendingIsUpdate, - filePath = warning.pendingFilePath, - ) - } - - _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) - currentAssetName = null - appendLog( - assetName = warning.pendingAssetName, - size = warning.pendingSizeBytes, - tag = warning.pendingReleaseTag, - result = if (warning.pendingIsUpdate) LogResult.Updated else LogResult.Installed, - ) - } catch (t: Throwable) { - logger.error("Install after override failed: ${t.message}") - _state.value = - _state.value.copy( - downloadStage = DownloadStage.IDLE, - installError = t.message, - ) - currentAssetName = null - } - } + overrideSigningKeyWarning() } DetailsAction.InstallPrimary -> { - val primary = _state.value.primaryAsset - val release = _state.value.selectedRelease - val installedApp = _state.value.installedApp - - if (primary != null && release != null) { - if (installedApp != null && - !installedApp.isPendingInstall && - normalizeVersion(release.tagName) != normalizeVersion(installedApp.installedVersion) && - platform == Platform.ANDROID - ) { - val isDowngrade = - isDowngradeVersion( - candidate = release.tagName, - current = installedApp.installedVersion, - allReleases = _state.value.allReleases, - ) - - if (isDowngrade) { - _state.update { - it.copy( - downgradeWarning = - DowngradeWarning( - packageName = installedApp.packageName, - currentVersion = installedApp.installedVersion, - targetVersion = release.tagName, - ), - ) - } - return - } - } - - installAsset( - downloadUrl = primary.downloadUrl, - assetName = primary.name, - sizeBytes = primary.size, - releaseTag = release.tagName, - ) - } + install() } DetailsAction.OnRequestUninstall -> { @@ -484,39 +184,11 @@ class DetailsViewModel( } DetailsAction.OnConfirmUninstall -> { - _state.update { it.copy(showUninstallConfirmation = false) } - val installedApp = _state.value.installedApp ?: return - logger.debug("Uninstalling app (confirmed): ${installedApp.packageName}") - viewModelScope.launch { - try { - installer.uninstall(installedApp.packageName) - } catch (e: Exception) { - logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") - _events.send( - DetailsEvent.OnMessage( - getString(Res.string.failed_to_uninstall, installedApp.packageName), - ), - ) - } - } + confirmUninstall() } DetailsAction.UninstallApp -> { - // Legacy direct uninstall (used from downgrade warning flow) - val installedApp = _state.value.installedApp ?: return - logger.debug("Uninstalling app: ${installedApp.packageName}") - viewModelScope.launch { - try { - installer.uninstall(installedApp.packageName) - } catch (e: Exception) { - logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") - _events.send( - DetailsEvent.OnMessage( - getString(Res.string.failed_to_uninstall, installedApp.packageName), - ), - ) - } - } + uninstallApp() } is DetailsAction.DownloadAsset -> { @@ -530,260 +202,45 @@ class DetailsViewModel( } DetailsAction.CancelCurrentDownload -> { - currentDownloadJob?.cancel() - currentDownloadJob = null - - val assetName = currentAssetName - if (assetName != null) { - cachedDownloadAssetName = assetName - val releaseTag = _state.value.selectedRelease?.tagName ?: "" - val totalSize = _state.value.totalBytes ?: _state.value.downloadedBytes - appendLog( - assetName = assetName, - tag = releaseTag, - size = totalSize, - result = LogResult.Cancelled, - ) - logger.debug("Download cancelled – keeping file for potential reuse: $assetName") - } - - currentAssetName = null - _state.value = - _state.value.copy( - isDownloading = false, - downloadProgressPercent = null, - downloadStage = DownloadStage.IDLE, - ) + cancelCurrentDownload() } DetailsAction.OnToggleFavorite -> { - viewModelScope.launch { - try { - val repo = _state.value.repository ?: return@launch - val selectedRelease = _state.value.selectedRelease - - val favoriteRepo = - FavoriteRepo( - repoId = repo.id, - repoName = repo.name, - repoOwner = repo.owner.login, - repoOwnerAvatarUrl = repo.owner.avatarUrl, - repoDescription = repo.description, - primaryLanguage = repo.language, - repoUrl = repo.htmlUrl, - latestVersion = selectedRelease?.tagName, - latestReleaseUrl = selectedRelease?.htmlUrl, - addedAt = System.now().toEpochMilliseconds(), - lastSyncedAt = System.now().toEpochMilliseconds(), - ) - - favouritesRepository.toggleFavorite(favoriteRepo) - - val newFavoriteState = favouritesRepository.isFavoriteSync(repo.id) - _state.value = _state.value.copy(isFavourite = newFavoriteState) - - _events.send( - element = - DetailsEvent.OnMessage( - message = - getString( - resource = - if (newFavoriteState) { - Res.string.added_to_favourites - } else { - Res.string.removed_from_favourites - }, - ), - ), - ) - } catch (t: Throwable) { - logger.error("Failed to toggle favorite: ${t.message}") - } - } + toggleFavourite() } DetailsAction.OnShareClick -> { - viewModelScope.launch { - _state.value.repository?.let { repo -> - runCatching { - shareManager.shareText("https://github-store.org/app?repo=${repo.fullName}") - }.onFailure { t -> - logger.error("Failed to share link: ${t.message}") - _events.send( - DetailsEvent.OnMessage(getString(Res.string.failed_to_share_link)), - ) - return@launch - } - - if (platform != Platform.ANDROID) { - _events.send(DetailsEvent.OnMessage(getString(Res.string.link_copied_to_clipboard))) - } - } - } + share() } DetailsAction.UpdateApp -> { - val installedApp = _state.value.installedApp - val selectedRelease = _state.value.selectedRelease - - if (installedApp != null && selectedRelease != null && installedApp.isUpdateAvailable) { - val latestAsset = - _state.value.installableAssets.firstOrNull { - it.name == installedApp.latestAssetName - } ?: _state.value.primaryAsset - - if (latestAsset != null) { - installAsset( - downloadUrl = latestAsset.downloadUrl, - assetName = latestAsset.name, - sizeBytes = latestAsset.size, - releaseTag = selectedRelease.tagName, - isUpdate = true, - ) - } - } + update() } DetailsAction.OpenApp -> { - val installedApp = _state.value.installedApp ?: return - val launched = installer.openApp(installedApp.packageName) - if (!launched) { - viewModelScope.launch { - _events.send( - DetailsEvent.OnMessage( - getString( - Res.string.failed_to_open_app, - installedApp.appName, - ), - ), - ) - } - } + openApp() } DetailsAction.OpenRepoInBrowser -> { _state.value.repository?.htmlUrl?.let { helper.openUrl(url = it) } - } - - DetailsAction.OpenAuthorInBrowser -> { - _state.value.userProfile?.htmlUrl?.let { - helper.openUrl(url = it) - } - } - - DetailsAction.OpenInObtainium -> { - val repo = _state.value.repository - repo?.owner?.login?.let { - installer.openInObtainium( - repoOwner = it, - repoName = repo.name, - onOpenInstaller = { - viewModelScope.launch { - _events.send( - DetailsEvent.OnOpenRepositoryInApp(OBTAINIUM_REPO_ID), - ) - } - }, - ) - } - _state.update { - it.copy(isInstallDropdownExpanded = false) - } - } - - DetailsAction.OpenInAppManager -> { - viewModelScope.launch { - try { - val primary = _state.value.primaryAsset - val release = _state.value.selectedRelease - - if (primary != null && release != null) { - currentAssetName = primary.name - - appendLog( - assetName = primary.name, - size = primary.size, - tag = release.tagName, - result = LogResult.PreparingForAppManager, - ) - - _state.value = - _state.value.copy( - downloadError = null, - installError = null, - downloadProgressPercent = null, - downloadStage = DownloadStage.DOWNLOADING, - ) - - downloader.download(primary.downloadUrl, primary.name).collect { p -> - _state.value = - _state.value.copy(downloadProgressPercent = p.percent) - if (p.percent == 100) { - _state.value = - _state.value.copy(downloadStage = DownloadStage.VERIFYING) - } - } - - val filePath = - downloader.getDownloadedFilePath(primary.name) - ?: throw IllegalStateException("Downloaded file not found") - - appendLog( - assetName = primary.name, - size = primary.size, - tag = release.tagName, - result = LogResult.Downloaded, - ) - - _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) - currentAssetName = null - - installer.openInAppManager( - filePath = filePath, - onOpenInstaller = { - viewModelScope.launch { - _events.send( - DetailsEvent.OnOpenRepositoryInApp(APP_MANAGER_REPO_ID), - ) - } - }, - ) - - appendLog( - assetName = primary.name, - size = primary.size, - tag = release.tagName, - result = LogResult.OpenedInAppManager, - ) - } - } catch (t: Throwable) { - logger.error("Failed to open in AppManager: ${t.message}") - _state.value = - _state.value.copy( - downloadStage = DownloadStage.IDLE, - installError = t.message, - ) - currentAssetName = null - - _state.value.primaryAsset?.let { asset -> - _state.value.selectedRelease?.let { release -> - appendLog( - assetName = asset.name, - size = asset.size, - tag = release.tagName, - result = LogResult.Error(t.message), - ) - } - } - } - } - _state.update { - it.copy(isInstallDropdownExpanded = false) + } + + DetailsAction.OpenAuthorInBrowser -> { + _state.value.userProfile?.htmlUrl?.let { + helper.openUrl(url = it) } } + DetailsAction.OpenInObtainium -> { + openObtainium() + } + + DetailsAction.OpenInAppManager -> { + openAppManager() + } + DetailsAction.OnToggleInstallDropdown -> { _state.update { it.copy(isInstallDropdownExpanded = !it.isInstallDropdownExpanded) @@ -791,26 +248,7 @@ class DetailsViewModel( } is DetailsAction.SelectReleaseCategory -> { - val newCategory = action.category - val filtered = - when (newCategory) { - ReleaseCategory.STABLE -> _state.value.allReleases.filter { !it.isPrerelease } - ReleaseCategory.PRE_RELEASE -> _state.value.allReleases.filter { it.isPrerelease } - ReleaseCategory.ALL -> _state.value.allReleases - } - val newSelected = filtered.firstOrNull() - val (installable, primary) = recomputeAssetsForRelease(newSelected) - - whatsNewTranslationJob?.cancel() - _state.update { - it.copy( - selectedReleaseCategory = newCategory, - selectedRelease = newSelected, - installableAssets = installable, - primaryAsset = primary, - whatsNewTranslation = TranslationState(), - ) - } + selectReleaseCategory(action) } is DetailsAction.SelectRelease -> { @@ -871,188 +309,598 @@ class DetailsViewModel( ) } - DetailsAction.ToggleAboutTranslation -> { - _state.update { - val current = it.aboutTranslation - it.copy(aboutTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) - } + DetailsAction.ToggleAboutTranslation -> { + _state.update { + val current = it.aboutTranslation + it.copy(aboutTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) + } + } + + DetailsAction.ToggleWhatsNewTranslation -> { + _state.update { + val current = it.whatsNewTranslation + it.copy(whatsNewTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) + } + } + + is DetailsAction.ShowLanguagePicker -> { + _state.update { + it.copy( + isLanguagePickerVisible = true, + languagePickerTarget = action.target, + ) + } + } + + DetailsAction.DismissLanguagePicker -> { + _state.update { + it.copy(isLanguagePickerVisible = false, languagePickerTarget = null) + } + } + + DetailsAction.OpenWithExternalInstaller -> { + openExternalInstaller() + } + + DetailsAction.DismissExternalInstallerPrompt -> { + _state.value = + _state.value.copy( + showExternalInstallerPrompt = false, + pendingInstallFilePath = null, + ) + } + + DetailsAction.InstallWithExternalApp -> { + installViaExternalApp() + } + + DetailsAction.OnNavigateBackClick -> { + // Handled in composable + } + + is DetailsAction.OpenDeveloperProfile -> { + // Handled in composable + } + + is DetailsAction.OnMessage -> { + // Handled in composable + } + + is DetailsAction.SelectDownloadAsset -> { + _state.update { state -> state.copy(primaryAsset = action.release) } + } + + DetailsAction.ToggleReleaseAssetsPicker -> { + _state.update { state -> state.copy(isReleaseSelectorVisible = !state.isReleaseSelectorVisible) } + } + } + } + + private fun observeLiquidGlassEnabled() { + viewModelScope.launch { + } + } + + private fun recomputeAssetsForRelease(release: GithubRelease?): Pair, GithubAsset?> { + val installable = + release + ?.assets + ?.filter { asset -> + installer.isAssetInstallable(asset.name) + }.orEmpty() + val primary = installer.choosePrimaryAsset(installable) + return installable to primary + } + + private fun observeInstalledApp(repoId: Long) { + viewModelScope.launch { + installedAppsRepository + .getAppByRepoIdAsFlow(repoId) + .distinctUntilChanged() + .collect { app -> + _state.update { it.copy(installedApp = app) } + } + } + } + + private fun installViaExternalApp() { + currentDownloadJob?.cancel() + val job = + viewModelScope.launch { + try { + val primary = _state.value.primaryAsset + val release = _state.value.selectedRelease + + if (primary != null && release != null) { + currentAssetName = primary.name + + appendLog( + assetName = primary.name, + size = primary.size, + tag = release.tagName, + result = LogResult.DownloadStarted, + ) + + _state.value = + _state.value.copy( + downloadError = null, + installError = null, + downloadProgressPercent = null, + downloadStage = DownloadStage.DOWNLOADING, + ) + + downloader + .download(primary.downloadUrl, primary.name) + .collect { p -> + _state.value = + _state.value.copy(downloadProgressPercent = p.percent) + if (p.percent == 100) { + _state.value = + _state.value.copy(downloadStage = DownloadStage.VERIFYING) + } + } + + val filePath = + downloader.getDownloadedFilePath(primary.name) + ?: throw IllegalStateException("Downloaded file not found") + + appendLog( + assetName = primary.name, + size = primary.size, + tag = release.tagName, + result = LogResult.Downloaded, + ) + + _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) + currentAssetName = null + + installer.openWithExternalInstaller(filePath) + + appendLog( + assetName = primary.name, + size = primary.size, + tag = release.tagName, + result = LogResult.OpenedInExternalInstaller, + ) + } + } catch (e: CancellationException) { + logger.debug("Install with external app cancelled") + _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) + currentAssetName = null + throw e + } catch (t: Throwable) { + logger.error("Failed to install with external app: ${t.message}") + _state.value = + _state.value.copy( + downloadStage = DownloadStage.IDLE, + installError = t.message, + ) + currentAssetName = null + + _state.value.primaryAsset?.let { asset -> + _state.value.selectedRelease?.let { release -> + appendLog( + assetName = asset.name, + size = asset.size, + tag = release.tagName, + result = Error(t.message), + ) + } + } + } + } + + currentDownloadJob = job + job.invokeOnCompletion { + if (currentDownloadJob === job) { + currentDownloadJob = null + } + } + + _state.update { + it.copy(isInstallDropdownExpanded = false) + } + } + + private fun openExternalInstaller() { + val filePath = _state.value.pendingInstallFilePath + if (filePath != null) { + try { + installer.openWithExternalInstaller(filePath) + _state.value.primaryAsset?.let { asset -> + _state.value.selectedRelease?.let { release -> + appendLog( + assetName = asset.name, + size = asset.size, + tag = release.tagName, + result = LogResult.OpenedInExternalInstaller, + ) + } + } + } catch (t: Throwable) { + logger.error("Failed to open with external installer: ${t.message}") + _state.value = _state.value.copy(installError = t.message) + } + } + _state.value = + _state.value.copy( + showExternalInstallerPrompt = false, + pendingInstallFilePath = null, + ) + } + + private fun selectReleaseCategory(action: DetailsAction.SelectReleaseCategory) { + val newCategory = action.category + val filtered = + when (newCategory) { + ReleaseCategory.STABLE -> _state.value.allReleases.filter { !it.isPrerelease } + ReleaseCategory.PRE_RELEASE -> _state.value.allReleases.filter { it.isPrerelease } + ReleaseCategory.ALL -> _state.value.allReleases + } + val newSelected = filtered.firstOrNull() + val (installable, primary) = recomputeAssetsForRelease(newSelected) + + whatsNewTranslationJob?.cancel() + _state.update { + it.copy( + selectedReleaseCategory = newCategory, + selectedRelease = newSelected, + installableAssets = installable, + primaryAsset = primary, + whatsNewTranslation = TranslationState(), + ) + } + } + + private fun openAppManager() { + viewModelScope.launch { + try { + val primary = _state.value.primaryAsset + val release = _state.value.selectedRelease + + if (primary != null && release != null) { + currentAssetName = primary.name + + appendLog( + assetName = primary.name, + size = primary.size, + tag = release.tagName, + result = LogResult.PreparingForAppManager, + ) + + _state.value = + _state.value.copy( + downloadError = null, + installError = null, + downloadProgressPercent = null, + downloadStage = DownloadStage.DOWNLOADING, + ) + + downloader.download(primary.downloadUrl, primary.name).collect { p -> + _state.value = + _state.value.copy(downloadProgressPercent = p.percent) + if (p.percent == 100) { + _state.value = + _state.value.copy(downloadStage = DownloadStage.VERIFYING) + } + } + + val filePath = + downloader.getDownloadedFilePath(primary.name) + ?: throw IllegalStateException("Downloaded file not found") + + appendLog( + assetName = primary.name, + size = primary.size, + tag = release.tagName, + result = LogResult.Downloaded, + ) + + _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) + currentAssetName = null + + installer.openInAppManager( + filePath = filePath, + onOpenInstaller = { + viewModelScope.launch { + _events.send( + DetailsEvent.OnOpenRepositoryInApp(APP_MANAGER_REPO_ID), + ) + } + }, + ) + + appendLog( + assetName = primary.name, + size = primary.size, + tag = release.tagName, + result = LogResult.OpenedInAppManager, + ) + } + } catch (t: Throwable) { + logger.error("Failed to open in AppManager: ${t.message}") + _state.value = + _state.value.copy( + downloadStage = DownloadStage.IDLE, + installError = t.message, + ) + currentAssetName = null + + _state.value.primaryAsset?.let { asset -> + _state.value.selectedRelease?.let { release -> + appendLog( + assetName = asset.name, + size = asset.size, + tag = release.tagName, + result = Error(t.message), + ) + } + } + } + } + _state.update { + it.copy(isInstallDropdownExpanded = false) + } + } + + private fun openObtainium() { + val repo = _state.value.repository + repo?.owner?.login?.let { + installer.openInObtainium( + repoOwner = it, + repoName = repo.name, + onOpenInstaller = { + viewModelScope.launch { + _events.send( + DetailsEvent.OnOpenRepositoryInApp(OBTAINIUM_REPO_ID), + ) + } + }, + ) + } + _state.update { + it.copy(isInstallDropdownExpanded = false) + } + } + + private fun openApp() { + val installedApp = _state.value.installedApp ?: return + val launched = installer.openApp(installedApp.packageName) + if (!launched) { + viewModelScope.launch { + _events.send( + DetailsEvent.OnMessage( + getString( + Res.string.failed_to_open_app, + installedApp.appName, + ), + ), + ) } + } + } - DetailsAction.ToggleWhatsNewTranslation -> { - _state.update { - val current = it.whatsNewTranslation - it.copy(whatsNewTranslation = current.copy(isShowingTranslation = !current.isShowingTranslation)) - } + private fun update() { + val installedApp = _state.value.installedApp + val selectedRelease = _state.value.selectedRelease + + if (installedApp != null && selectedRelease != null && installedApp.isUpdateAvailable) { + val latestAsset = + _state.value.installableAssets.firstOrNull { + it.name == installedApp.latestAssetName + } ?: _state.value.primaryAsset + + if (latestAsset != null) { + installAsset( + downloadUrl = latestAsset.downloadUrl, + assetName = latestAsset.name, + sizeBytes = latestAsset.size, + releaseTag = selectedRelease.tagName, + isUpdate = true, + ) } + } + } - is DetailsAction.ShowLanguagePicker -> { - _state.update { - it.copy( - isLanguagePickerVisible = true, - languagePickerTarget = action.target, + private fun share() { + viewModelScope.launch { + _state.value.repository?.let { repo -> + runCatching { + shareManager.shareText("https://github-store.org/app?repo=${repo.fullName}") + }.onFailure { t -> + logger.error("Failed to share link: ${t.message}") + _events.send( + DetailsEvent.OnMessage(getString(Res.string.failed_to_share_link)), ) + return@launch } - } - DetailsAction.DismissLanguagePicker -> { - _state.update { - it.copy(isLanguagePickerVisible = false, languagePickerTarget = null) + if (platform != Platform.ANDROID) { + _events.send(DetailsEvent.OnMessage(getString(Res.string.link_copied_to_clipboard))) } } + } + } - DetailsAction.OpenWithExternalInstaller -> { - val filePath = _state.value.pendingInstallFilePath - if (filePath != null) { - try { - installer.openWithExternalInstaller(filePath) - _state.value.primaryAsset?.let { asset -> - _state.value.selectedRelease?.let { release -> - appendLog( - assetName = asset.name, - size = asset.size, - tag = release.tagName, - result = LogResult.OpenedInExternalInstaller, - ) - } - } - } catch (t: Throwable) { - logger.error("Failed to open with external installer: ${t.message}") - _state.value = _state.value.copy(installError = t.message) - } - } - _state.value = - _state.value.copy( - showExternalInstallerPrompt = false, - pendingInstallFilePath = null, - ) - } + private fun toggleFavourite() { + viewModelScope.launch { + try { + val repo = _state.value.repository ?: return@launch + val selectedRelease = _state.value.selectedRelease - DetailsAction.DismissExternalInstallerPrompt -> { - _state.value = - _state.value.copy( - showExternalInstallerPrompt = false, - pendingInstallFilePath = null, + val favoriteRepo = + FavoriteRepo( + repoId = repo.id, + repoName = repo.name, + repoOwner = repo.owner.login, + repoOwnerAvatarUrl = repo.owner.avatarUrl, + repoDescription = repo.description, + primaryLanguage = repo.language, + repoUrl = repo.htmlUrl, + latestVersion = selectedRelease?.tagName, + latestReleaseUrl = selectedRelease?.htmlUrl, + addedAt = System.now().toEpochMilliseconds(), + lastSyncedAt = System.now().toEpochMilliseconds(), ) - } - DetailsAction.InstallWithExternalApp -> { - currentDownloadJob?.cancel() - val job = - viewModelScope.launch { - try { - val primary = _state.value.primaryAsset - val release = _state.value.selectedRelease + favouritesRepository.toggleFavorite(favoriteRepo) - if (primary != null && release != null) { - currentAssetName = primary.name + val newFavoriteState = favouritesRepository.isFavoriteSync(repo.id) + _state.value = _state.value.copy(isFavourite = newFavoriteState) - appendLog( - assetName = primary.name, - size = primary.size, - tag = release.tagName, - result = LogResult.DownloadStarted, - ) + _events.send( + element = + DetailsEvent.OnMessage( + message = + getString( + resource = + if (newFavoriteState) { + Res.string.added_to_favourites + } else { + Res.string.removed_from_favourites + }, + ), + ), + ) + } catch (t: Throwable) { + logger.error("Failed to toggle favorite: ${t.message}") + } + } + } - _state.value = - _state.value.copy( - downloadError = null, - installError = null, - downloadProgressPercent = null, - downloadStage = DownloadStage.DOWNLOADING, - ) + private fun cancelCurrentDownload() { + currentDownloadJob?.cancel() + currentDownloadJob = null - downloader - .download(primary.downloadUrl, primary.name) - .collect { p -> - _state.value = - _state.value.copy(downloadProgressPercent = p.percent) - if (p.percent == 100) { - _state.value = - _state.value.copy(downloadStage = DownloadStage.VERIFYING) - } - } - - val filePath = - downloader.getDownloadedFilePath(primary.name) - ?: throw IllegalStateException("Downloaded file not found") - - appendLog( - assetName = primary.name, - size = primary.size, - tag = release.tagName, - result = LogResult.Downloaded, - ) + val assetName = currentAssetName + if (assetName != null) { + cachedDownloadAssetName = assetName + val releaseTag = _state.value.selectedRelease?.tagName ?: "" + val totalSize = _state.value.totalBytes ?: _state.value.downloadedBytes + appendLog( + assetName = assetName, + tag = releaseTag, + size = totalSize, + result = LogResult.Cancelled, + ) + logger.debug("Download cancelled – keeping file for potential reuse: $assetName") + } - _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) - currentAssetName = null + currentAssetName = null + _state.value = + _state.value.copy( + isDownloading = false, + downloadProgressPercent = null, + downloadStage = DownloadStage.IDLE, + ) + } - installer.openWithExternalInstaller(filePath) + private fun uninstallApp() { + val installedApp = _state.value.installedApp ?: return + logger.debug("Uninstalling app: ${installedApp.packageName}") + viewModelScope.launch { + try { + installer.uninstall(installedApp.packageName) + } catch (e: Exception) { + logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") + _events.send( + DetailsEvent.OnMessage( + getString(Res.string.failed_to_uninstall, installedApp.packageName), + ), + ) + } + } + } - appendLog( - assetName = primary.name, - size = primary.size, - tag = release.tagName, - result = LogResult.OpenedInExternalInstaller, - ) - } - } catch (e: CancellationException) { - logger.debug("Install with external app cancelled") - _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) - currentAssetName = null - throw e - } catch (t: Throwable) { - logger.error("Failed to install with external app: ${t.message}") - _state.value = - _state.value.copy( - downloadStage = DownloadStage.IDLE, - installError = t.message, - ) - currentAssetName = null - - _state.value.primaryAsset?.let { asset -> - _state.value.selectedRelease?.let { release -> - appendLog( - assetName = asset.name, - size = asset.size, - tag = release.tagName, - result = Error(t.message), - ) - } - } - } - } + private fun install() { + val primary = _state.value.primaryAsset + val release = _state.value.selectedRelease + val installedApp = _state.value.installedApp + + if (primary != null && release != null) { + if (installedApp != null && + !installedApp.isPendingInstall && + VersionHelper.normalizeVersion(release.tagName) != + VersionHelper.normalizeVersion( + installedApp.installedVersion, + ) && + platform == Platform.ANDROID + ) { + val isDowngrade = + VersionHelper.isDowngradeVersion( + candidate = release.tagName, + current = installedApp.installedVersion, + allReleases = _state.value.allReleases, + ) - currentDownloadJob = job - job.invokeOnCompletion { - if (currentDownloadJob === job) { - currentDownloadJob = null + if (isDowngrade) { + _state.update { + it.copy( + downgradeWarning = + DowngradeWarning( + packageName = installedApp.packageName, + currentVersion = installedApp.installedVersion, + targetVersion = release.tagName, + ), + ) } + return } - - _state.update { - it.copy(isInstallDropdownExpanded = false) - } - } - - DetailsAction.OnNavigateBackClick -> { - // Handled in composable } - is DetailsAction.OpenDeveloperProfile -> { - // Handled in composable - } + installAsset( + downloadUrl = primary.downloadUrl, + assetName = primary.name, + sizeBytes = primary.size, + releaseTag = release.tagName, + ) + } + } - is DetailsAction.OnMessage -> { - // Handled in composable - } + private fun overrideSigningKeyWarning() { + val warning = _state.value.signingKeyWarning ?: return + dismissDowngradeWarning() + viewModelScope.launch { + try { + val ext = warning.pendingAssetName.substringAfterLast('.', "").lowercase() + installer.install(warning.pendingFilePath, ext) + + if (platform == Platform.ANDROID) { + saveInstalledAppToDatabase( + assetName = warning.pendingAssetName, + assetUrl = warning.pendingDownloadUrl, + assetSize = warning.pendingSizeBytes, + releaseTag = warning.pendingReleaseTag, + isUpdate = warning.pendingIsUpdate, + filePath = warning.pendingFilePath, + ) + } - is DetailsAction.SelectDownloadAsset -> { - _state.update { state -> state.copy(primaryAsset = action.release) } + _state.value = _state.value.copy(downloadStage = DownloadStage.IDLE) + currentAssetName = null + appendLog( + assetName = warning.pendingAssetName, + size = warning.pendingSizeBytes, + tag = warning.pendingReleaseTag, + result = if (warning.pendingIsUpdate) LogResult.Updated else LogResult.Installed, + ) + } catch (t: Throwable) { + logger.error("Install after override failed: ${t.message}") + _state.value = + _state.value.copy( + downloadStage = DownloadStage.IDLE, + installError = t.message, + ) + currentAssetName = null } + } + } - DetailsAction.ToggleReleaseAssetsPicker -> { - _state.update { state -> state.copy(isReleaseSelectorVisible = !state.isReleaseSelectorVisible) } - } + private fun dismissDowngradeWarning() { + _state.update { + it.copy( + downgradeWarning = null, + ) } } @@ -1119,78 +967,87 @@ class DetailsViewModel( val isApk = ext == "apk" if (isApk) { - val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) - if (apkInfo == null) { - logger.error("Failed to extract APK info for $assetName") - _state.value = _state.value.copy( - downloadStage = DownloadStage.IDLE, - installError = "Failed to verify APK package info", - ) - currentAssetName = null - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = Error("Failed to extract APK info"), - ) - return - } - - // Validate package name matches on updates - val trackedApp = _state.value.installedApp - if (isUpdate && trackedApp != null && apkInfo.packageName != trackedApp.packageName) { - logger.error("Package name mismatch on update: APK=${apkInfo.packageName}, installed=${trackedApp.packageName}") - _state.value = _state.value.copy( - downloadStage = DownloadStage.IDLE, - installError = getString( - Res.string.update_package_mismatch, - apkInfo.packageName, - trackedApp.packageName, - ), - ) - currentAssetName = null - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = Error("Package name mismatch"), + val validationResult = + installationManager.validateApk( + filePath = filePath, + isUpdate = isUpdate, + trackedPackageName = _state.value.installedApp?.packageName, ) - return - } - val result = - checkFingerprints( - apkPackageInfo = apkInfo, - ) + when (validationResult) { + is ApkValidationResult.ExtractionFailed -> { + logger.error("Failed to extract APK info for $assetName") + _state.value = + _state.value.copy( + downloadStage = DownloadStage.IDLE, + installError = "Failed to verify APK package info", + ) + currentAssetName = null + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = Error("Failed to extract APK info"), + ) + return + } - result - .onFailure { - val existingApp = - installedAppsRepository.getAppByPackage(apkInfo.packageName) - _state.update { state -> - state.copy( - signingKeyWarning = - SigningKeyWarning( - packageName = apkInfo.packageName, - expectedFingerprint = existingApp?.signingFingerprint ?: "", - actualFingerprint = apkInfo.signingFingerprint ?: "", - pendingDownloadUrl = downloadUrl, - pendingAssetName = assetName, - pendingSizeBytes = sizeBytes, - pendingReleaseTag = releaseTag, - pendingIsUpdate = isUpdate, - pendingFilePath = filePath, + is ApkValidationResult.PackageMismatch -> { + logger.error( + "Package name mismatch on update: " + + "APK=${validationResult.apkPackageName}, " + + "installed=${validationResult.installedPackageName}", + ) + _state.value = + _state.value.copy( + downloadStage = DownloadStage.IDLE, + installError = + getString( + Res.string.update_package_mismatch, + validationResult.apkPackageName, + validationResult.installedPackageName, ), ) - } + currentAssetName = null appendLog( assetName = assetName, size = sizeBytes, tag = releaseTag, - result = Error("Signing key changed"), + result = Error("Package name mismatch"), ) return } + + is ApkValidationResult.Valid -> { + val fpResult = + installationManager.checkSigningFingerprint(validationResult.apkInfo) + if (fpResult is FingerprintCheckResult.Mismatch) { + _state.update { state -> + state.copy( + signingKeyWarning = + SigningKeyWarning( + packageName = validationResult.apkInfo.packageName, + expectedFingerprint = fpResult.expectedFingerprint, + actualFingerprint = fpResult.actualFingerprint, + pendingDownloadUrl = downloadUrl, + pendingAssetName = assetName, + pendingSizeBytes = sizeBytes, + pendingReleaseTag = releaseTag, + pendingIsUpdate = isUpdate, + pendingFilePath = filePath, + ), + ) + } + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = Error("Signing key changed"), + ) + return + } + } + } } val installOutcome = installer.install(filePath, ext) @@ -1229,26 +1086,6 @@ class DetailsViewModel( ) } - private suspend fun checkFingerprints(apkPackageInfo: ApkPackageInfo): Result { - val existingApp = - installedAppsRepository.getAppByPackage(apkPackageInfo.packageName) - ?: return Result.success(Unit) - - if (existingApp.signingFingerprint == null) return Result.success(Unit) - - if (apkPackageInfo.signingFingerprint == null) return Result.success(Unit) - - return if (existingApp.signingFingerprint == apkPackageInfo.signingFingerprint) { - Result.success(Unit) - } else { - Result.failure( - IllegalStateException( - "Signing key changed! Expected: ${existingApp.signingFingerprint}, got: ${apkPackageInfo.signingFingerprint}", - ), - ) - } - } - private fun launchAttestationCheck(filePath: String) { val repo = _state.value.repository ?: return val owner = repo.owner.login @@ -1257,32 +1094,14 @@ class DetailsViewModel( _state.update { it.copy(attestationStatus = AttestationStatus.CHECKING) } viewModelScope.launch { - try { - val digest = computeSha256(filePath) - val verified = detailsRepository.checkAttestations(owner, repoName, digest) - _state.update { - it.copy( - attestationStatus = - if (verified) AttestationStatus.VERIFIED else AttestationStatus.UNVERIFIED, - ) - } - } catch (e: Exception) { - logger.debug("Attestation check error: ${e.message}") - _state.update { it.copy(attestationStatus = AttestationStatus.UNVERIFIED) } - } - } - } - - private fun computeSha256(filePath: String): String { - val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(8192) - FileInputStream(File(filePath)).use { fis -> - var bytesRead: Int - while (fis.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) + val verified = attestationVerifier.verify(owner, repoName, filePath) + _state.update { + it.copy( + attestationStatus = + if (verified) AttestationStatus.VERIFIED else AttestationStatus.UNVERIFIED, + ) } } - return digest.digest().joinToString("") { "%02x".format(it) } } private suspend fun downloadAsset( @@ -1379,112 +1198,57 @@ class DetailsViewModel( appendLog( assetName = assetName, size = sizeBytes, - tag = releaseTag, - result = LogResult.PermissionBlocked, - ) - return null - } - - return filePath - } - - @OptIn(ExperimentalTime::class) - private suspend fun saveInstalledAppToDatabase( - assetName: String, - assetUrl: String, - assetSize: Long, - releaseTag: String, - isUpdate: Boolean, - filePath: String, - installOutcome: InstallOutcome = InstallOutcome.DELEGATED_TO_SYSTEM, - ) { - try { - val repo = _state.value.repository ?: return - - val apkInfo: ApkPackageInfo = - if (platform == Platform.ANDROID && assetName.lowercase().endsWith(".apk")) { - val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) - if (apkInfo != null) { - ApkPackageInfo( - packageName = apkInfo.packageName, - appName = apkInfo.appName, - versionName = apkInfo.versionName, - versionCode = apkInfo.versionCode, - signingFingerprint = apkInfo.signingFingerprint, - ) - } else { - logger.error("Failed to extract APK info for $assetName") - return - } - } else { - return - } - - if (isUpdate) { - installedAppsRepository.updateAppVersion( - packageName = apkInfo.packageName, - newTag = releaseTag, - newAssetName = assetName, - newAssetUrl = assetUrl, - newVersionName = apkInfo.versionName, - newVersionCode = apkInfo.versionCode, - signingFingerprint = apkInfo.signingFingerprint, - ) - } else { - val installedApp = - InstalledApp( - packageName = apkInfo.packageName, - repoId = repo.id, - repoName = repo.name, - repoOwner = repo.owner.login, - repoOwnerAvatarUrl = repo.owner.avatarUrl, - repoDescription = repo.description, - primaryLanguage = repo.language, - repoUrl = repo.htmlUrl, - installedVersion = releaseTag, - installedAssetName = assetName, - installedAssetUrl = assetUrl, - latestVersion = releaseTag, - latestAssetName = assetName, - latestAssetUrl = assetUrl, - latestAssetSize = assetSize, - appName = apkInfo.appName, - installSource = InstallSource.THIS_APP, - installedAt = System.now().toEpochMilliseconds(), - lastCheckedAt = System.now().toEpochMilliseconds(), - lastUpdatedAt = System.now().toEpochMilliseconds(), - isUpdateAvailable = false, - updateCheckEnabled = true, - releaseNotes = "", - systemArchitecture = installer.detectSystemArchitecture().name, - fileExtension = assetName.substringAfterLast('.', ""), - isPendingInstall = installOutcome != InstallOutcome.COMPLETED, - installedVersionName = apkInfo.versionName, - installedVersionCode = apkInfo.versionCode, - latestVersionName = apkInfo.versionName, - latestVersionCode = apkInfo.versionCode, - signingFingerprint = apkInfo.signingFingerprint, - ) + tag = releaseTag, + result = LogResult.PermissionBlocked, + ) + return null + } - installedAppsRepository.saveInstalledApp(installedApp) - } + return filePath + } - if (_state.value.isFavourite) { - favouritesRepository.updateFavoriteInstallStatus( - repoId = repo.id, - installed = true, - packageName = apkInfo.packageName, - ) - } + private suspend fun saveInstalledAppToDatabase( + assetName: String, + assetUrl: String, + assetSize: Long, + releaseTag: String, + isUpdate: Boolean, + filePath: String, + installOutcome: InstallOutcome = InstallOutcome.DELEGATED_TO_SYSTEM, + ) { + val repo = _state.value.repository ?: return + if (platform != Platform.ANDROID || !assetName.lowercase().endsWith(".apk")) return - delay(1000) - val updatedApp = installedAppsRepository.getAppByPackage(apkInfo.packageName) - _state.value = _state.value.copy(installedApp = updatedApp) + val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) + if (apkInfo == null) { + logger.error("Failed to extract APK info for $assetName") + return + } - logger.debug("Successfully saved and reloaded app: ${updatedApp?.packageName}") - } catch (t: Throwable) { - logger.error("Failed to save installed app to database: ${t.message}") - t.printStackTrace() + if (isUpdate) { + installationManager.updateInstalledAppVersion( + UpdateInstalledAppParams( + apkInfo = apkInfo, + assetName = assetName, + assetUrl = assetUrl, + releaseTag = releaseTag, + ), + ) + } else { + val reloaded = + installationManager.saveNewInstalledApp( + SaveInstalledAppParams( + repo = repo, + apkInfo = apkInfo, + assetName = assetName, + assetUrl = assetUrl, + assetSize = assetSize, + releaseTag = releaseTag, + isPendingInstall = installOutcome != InstallOutcome.COMPLETED, + isFavourite = _state.value.isFavourite, + ), + ) + _state.value = _state.value.copy(installedApp = reloaded) } } @@ -1602,6 +1366,222 @@ class DetailsViewModel( } } + @OptIn(ExperimentalTime::class) + private fun loadInitial() { + viewModelScope.launch { + try { + rateLimited.set(false) + + _state.value = _state.value.copy(isLoading = true, errorMessage = null) + + val syncResult = syncInstalledAppsUseCase() + if (syncResult.isFailure) { + logger.warn("Sync had issues but continuing: ${syncResult.exceptionOrNull()?.message}") + } + + val repo = + if (ownerParam.isNotEmpty() && repoParam.isNotEmpty()) { + detailsRepository.getRepositoryByOwnerAndName(ownerParam, repoParam) + } else { + detailsRepository.getRepositoryById(repositoryId) + } + launch { seenReposRepository.markAsSeen(repo.id) } + + val isFavoriteDeferred = + async { + try { + favouritesRepository.isFavoriteSync(repo.id) + } catch (_: RateLimitException) { + rateLimited.set(true) + null + } catch (t: Throwable) { + logger.error("Failed to load if repo is favourite: ${t.localizedMessage}") + false + } + } + val isFavorite = isFavoriteDeferred.await() + val isStarredDeferred = + async { + try { + starredRepository.isStarred(repo.id) + } catch (_: RateLimitException) { + rateLimited.set(true) + null + } catch (t: Throwable) { + logger.error("Failed to load if repo is starred: ${t.localizedMessage}") + false + } + } + val isStarred = isStarredDeferred.await() + + val owner = repo.owner.login + val name = repo.name + + _state.value = + _state.value.copy( + repository = repo, + isFavourite = isFavorite == true, + isStarred = isStarred == true, + ) + + val allReleasesDeferred = + async { + try { + detailsRepository.getAllReleases( + owner = owner, + repo = name, + defaultBranch = repo.defaultBranch, + ) + } catch (_: RateLimitException) { + rateLimited.set(true) + emptyList() + } catch (t: Throwable) { + logger.warn("Failed to load releases: ${t.message}") + emptyList() + } + } + + val statsDeferred = + async { + try { + detailsRepository.getRepoStats(owner, name) + } catch (_: RateLimitException) { + rateLimited.set(true) + null + } catch (_: Throwable) { + null + } + } + + val readmeDeferred = + async { + try { + detailsRepository.getReadme( + owner = owner, + repo = name, + defaultBranch = repo.defaultBranch, + ) + } catch (_: RateLimitException) { + rateLimited.set(true) + null + } catch (_: Throwable) { + null + } + } + + val userProfileDeferred = + async { + try { + detailsRepository.getUserProfile(owner) + } catch (_: RateLimitException) { + rateLimited.set(true) + null + } catch (t: Throwable) { + logger.warn("Failed to load user profile: ${t.message}") + null + } + } + + val installedAppDeferred = + async { + try { + val dbApp = installedAppsRepository.getAppByRepoId(repo.id) + + if (dbApp != null) { + if (dbApp.isPendingInstall && + packageMonitor.isPackageInstalled(dbApp.packageName) + ) { + installedAppsRepository.updatePendingStatus( + dbApp.packageName, + false, + ) + installedAppsRepository.getAppByPackage(dbApp.packageName) + } else { + dbApp + } + } else { + null + } + } catch (_: RateLimitException) { + rateLimited.set(true) + null + } catch (t: Throwable) { + logger.error("Failed to load installed app: ${t.message}") + null + } + } + + val isObtainiumEnabled = platform == Platform.ANDROID + val isAppManagerEnabled = platform == Platform.ANDROID + + val allReleases = allReleasesDeferred.await() + val stats = statsDeferred.await() + val readme = readmeDeferred.await() + val userProfile = userProfileDeferred.await() + val installedApp = installedAppDeferred.await() + + if (rateLimited.get()) { + _state.value = _state.value.copy(isLoading = false, errorMessage = null) + return@launch + } + + val selectedRelease = + allReleases.firstOrNull { !it.isPrerelease } + ?: allReleases.firstOrNull() + + val (installable, primary) = recomputeAssetsForRelease(selectedRelease) + + val isObtainiumAvailable = installer.isObtainiumInstalled() + val isAppManagerAvailable = installer.isAppManagerInstalled() + + val liquidGlassEnabled = tweaksRepository.getLiquidGlassEnabled().first() + + logger.debug("Loaded repo: ${repo.name}, installedApp: ${installedApp?.packageName}") + + _state.value = + _state.value.copy( + isLoading = false, + errorMessage = null, + repository = repo, + allReleases = allReleases, + selectedRelease = selectedRelease, + selectedReleaseCategory = ReleaseCategory.STABLE, + stats = stats, + readmeMarkdown = readme?.first, + readmeLanguage = readme?.second, + installableAssets = installable, + primaryAsset = primary, + userProfile = userProfile, + systemArchitecture = installer.detectSystemArchitecture(), + isObtainiumAvailable = isObtainiumAvailable, + isObtainiumEnabled = isObtainiumEnabled, + isAppManagerAvailable = isAppManagerAvailable, + isAppManagerEnabled = isAppManagerEnabled, + installedApp = installedApp, + deviceLanguageCode = translationRepository.getDeviceLanguageCode(), + isComingFromUpdate = isComingFromUpdate, + isLiquidGlassEnabled = liquidGlassEnabled, + ) + + observeInstalledApp(repo.id) + } catch (e: RateLimitException) { + logger.error("Rate limited: ${e.message}") + _state.value = + _state.value.copy( + isLoading = false, + errorMessage = getString(Res.string.rate_limit_exceeded), + ) + } catch (t: Throwable) { + logger.error("Details load failed: ${t.message}") + _state.value = + _state.value.copy( + isLoading = false, + errorMessage = t.message ?: "Failed to load details", + ) + } + } + } + private fun translateContent( text: String, targetLanguageCode: String, @@ -1656,65 +1636,6 @@ class DetailsViewModel( } } - private fun normalizeVersion(version: String?): String = version?.removePrefix("v")?.removePrefix("V")?.trim() ?: "" - - /** - * Returns true if [candidate] is strictly older than [current]. - * Uses list-index order as primary heuristic (releases are newest-first), - * and falls back to semantic version comparison when list lookup fails. - */ - private fun isDowngradeVersion( - candidate: String, - current: String, - allReleases: List, - ): Boolean { - val normalizedCandidate = normalizeVersion(candidate) - val normalizedCurrent = normalizeVersion(current) - - if (normalizedCandidate == normalizedCurrent) return false - - val candidateIndex = - allReleases.indexOfFirst { - normalizeVersion(it.tagName) == normalizedCandidate - } - val currentIndex = - allReleases.indexOfFirst { - normalizeVersion(it.tagName) == normalizedCurrent - } - - if (candidateIndex != -1 && currentIndex != -1) { - return candidateIndex > currentIndex - } - - return compareSemanticVersions(normalizedCandidate, normalizedCurrent) < 0 - } - - /** - * Compares two semantic version strings. Returns positive if a > b, negative if a < b, 0 if equal. - */ - private fun compareSemanticVersions( - a: String, - b: String, - ): Int { - val aCore = a.split("-", limit = 2) - val bCore = b.split("-", limit = 2) - val aParts = aCore[0].split(".") - val bParts = bCore[0].split(".") - - val maxLen = maxOf(aParts.size, bParts.size) - for (i in 0 until maxLen) { - val aPart = aParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L - val bPart = bParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L - if (aPart != bPart) return aPart.compareTo(bPart) - } - - val aHasPre = aCore.size > 1 - val bHasPre = bCore.size > 1 - if (aHasPre != bHasPre) return if (aHasPre) -1 else 1 - - return 0 - } - private companion object { const val OBTAINIUM_REPO_ID: Long = 523534328 const val APP_MANAGER_REPO_ID: Long = 268006778 From a482aec353421db24eb22383606b09a44d72211e Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 21 Mar 2026 14:59:28 +0500 Subject: [PATCH 3/3] feat: track pending installation status for updated apps - Move domain models `ApkValidationResult`, `FingerprintCheckResult`, `SaveInstalledAppParams`, and `UpdateInstalledAppParams` to the `domain.model` package. - Update `UpdateInstalledAppParams` and the database save/update logic to include an `isPendingInstall` flag based on the `InstallOutcome`. - Update `InstallationManagerImpl` to persist the pending status in the repository when updating an app version. - Modify `DetailsViewModel` to pass the installation outcome when saving or updating app records after an install attempt. --- .../details/data/system/InstallationManagerImpl.kt | 12 +++++++----- .../details/domain/model/UpdateInstalledAppParams.kt | 1 + .../rainxch/details/presentation/DetailsViewModel.kt | 12 +++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt index 6cbddca4..7bacaa3a 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt @@ -8,11 +8,11 @@ import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.Installer -import zed.rainxch.details.domain.system.ApkValidationResult -import zed.rainxch.details.domain.system.FingerprintCheckResult +import zed.rainxch.details.domain.model.ApkValidationResult +import zed.rainxch.details.domain.model.FingerprintCheckResult +import zed.rainxch.details.domain.model.SaveInstalledAppParams +import zed.rainxch.details.domain.model.UpdateInstalledAppParams import zed.rainxch.details.domain.system.InstallationManager -import zed.rainxch.details.domain.system.SaveInstalledAppParams -import zed.rainxch.details.domain.system.UpdateInstalledAppParams import kotlin.time.Clock.System import kotlin.time.ExperimentalTime @@ -120,8 +120,9 @@ class InstallationManagerImpl( } override suspend fun updateInstalledAppVersion(params: UpdateInstalledAppParams) { + val packageName = params.apkInfo.packageName installedAppsRepository.updateAppVersion( - packageName = params.apkInfo.packageName, + packageName = packageName, newTag = params.releaseTag, newAssetName = params.assetName, newAssetUrl = params.assetUrl, @@ -129,5 +130,6 @@ class InstallationManagerImpl( newVersionCode = params.apkInfo.versionCode, signingFingerprint = params.apkInfo.signingFingerprint, ) + installedAppsRepository.updatePendingStatus(packageName, params.isPendingInstall) } } diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt index 2c814971..dafca653 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/UpdateInstalledAppParams.kt @@ -7,4 +7,5 @@ data class UpdateInstalledAppParams( val assetName: String, val assetUrl: String, val releaseTag: String, + val isPendingInstall: Boolean, ) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index f2dbf51d..a696c6f1 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -42,12 +42,12 @@ import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.repository.DetailsRepository import zed.rainxch.details.domain.repository.TranslationRepository -import zed.rainxch.details.domain.system.ApkValidationResult +import zed.rainxch.details.domain.model.ApkValidationResult +import zed.rainxch.details.domain.model.FingerprintCheckResult +import zed.rainxch.details.domain.model.SaveInstalledAppParams +import zed.rainxch.details.domain.model.UpdateInstalledAppParams import zed.rainxch.details.domain.system.AttestationVerifier -import zed.rainxch.details.domain.system.FingerprintCheckResult import zed.rainxch.details.domain.system.InstallationManager -import zed.rainxch.details.domain.system.SaveInstalledAppParams -import zed.rainxch.details.domain.system.UpdateInstalledAppParams import zed.rainxch.details.domain.util.VersionHelper import zed.rainxch.details.presentation.model.AttestationStatus import zed.rainxch.details.presentation.model.DowngradeWarning @@ -863,7 +863,7 @@ class DetailsViewModel( viewModelScope.launch { try { val ext = warning.pendingAssetName.substringAfterLast('.', "").lowercase() - installer.install(warning.pendingFilePath, ext) + val installOutcome = installer.install(warning.pendingFilePath, ext) if (platform == Platform.ANDROID) { saveInstalledAppToDatabase( @@ -873,6 +873,7 @@ class DetailsViewModel( releaseTag = warning.pendingReleaseTag, isUpdate = warning.pendingIsUpdate, filePath = warning.pendingFilePath, + installOutcome = installOutcome, ) } @@ -1232,6 +1233,7 @@ class DetailsViewModel( assetName = assetName, assetUrl = assetUrl, releaseTag = releaseTag, + isPendingInstall = installOutcome != InstallOutcome.COMPLETED, ), ) } else {