Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}" }

Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment on lines 354 to 374
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Return COMPLETED for the synchronous desktop paths.

This now always reports DELEGATED_TO_SYSTEM, but several branches finish before install(...) returns: installAppImage(), successful installDebPackage() / installRpmPackage() runs, and the successful .pkg flow that waits for installer. Any caller that relies on InstallOutcome will misclassify those installs as pending. Thread the outcome back from the platform helpers instead of hard-coding it here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt`
around lines 354 - 374, The install(...) function currently ignores the outcome
from platform-specific installers and always returns
InstallOutcome.DELEGATED_TO_SYSTEM; change it to capture and return the
InstallOutcome returned by installWindows(file, ext), installMacOS(file, ext),
or installLinux(file, ext) so synchronous flows (e.g., installAppImage,
successful installDebPackage/installRpmPackage, and the .pkg installer flow) can
return InstallOutcome.COMPLETED when appropriate; update the when(...)
expression to assign its result to a variable (or directly return the when
result) and ensure the method returns that InstallOutcome instead of the
hard-coded value.

}

private fun installWindows(
file: File,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -11,7 +30,7 @@ interface Installer {
suspend fun install(
filePath: String,
extOrMime: String,
)
): InstallOutcome

fun uninstall(packageName: String)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,4 +26,20 @@ val detailsModule =
localizationManager = get(),
)
}

single<AttestationVerifier> {
AttestationVerifierImpl(
detailsRepository = get(),
logger = get(),
)
}

single<InstallationManager> {
InstallationManagerImpl(
installer = get(),
installedAppsRepository = get(),
favouritesRepository = get(),
logger = get(),
)
}
}
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +6 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== JVM-specific imports under commonMain ==="
rg -n -g '**/commonMain/**/*.kt' '^import java\.'

echo
echo "=== JVM-style format calls under commonMain ==="
rg -n -g '**/commonMain/**/*.kt' '\.format\s*\('

echo
echo "=== AttestationVerifierImpl.kt ==="
fd 'AttestationVerifierImpl\.kt$' --exec sed -n '1,80p' {}

Repository: OpenHub-Store/GitHub-Store

Length of output: 4909


Move JVM-only digest implementation out of commonMain.

Lines 6-8 import JVM-specific APIs (java.io.File, java.io.FileInputStream, java.security.MessageDigest), and line 36 uses String.format() which is not available across all Kotlin Multiplatform targets. This breaks the shared code requirement. Move computeSha256() to a platform-specific source set (jvmMain for Desktop, androidMain for Android), or define the method as expect in commonMain and provide actual implementations per platform.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/AttestationVerifierImpl.kt`
around lines 6 - 8, The imports and JVM-specific implementation (File,
FileInputStream, MessageDigest) and use of String.format in
AttestationVerifierImpl.kt are platform-specific and must be moved out of
commonMain; refactor by removing computeSha256() from commonMain and either (A)
declare an expect fun computeSha256(path: String): String in commonMain and
implement actual fun computeSha256 in jvmMain/androidMain using
File/FileInputStream/MessageDigest and platform-safe string formatting, or (B)
move the entire computeSha256 implementation into jvmMain/androidMain and call
it from AttestationVerifierImpl; update references to use the platform
implementation and replace String.format with platform-appropriate formatting
(e.g., Kotlin string templates or platform-specific formatter) in the platform
files.


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) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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.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 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) {
val packageName = params.apkInfo.packageName
installedAppsRepository.updateAppVersion(
packageName = packageName,
newTag = params.releaseTag,
newAssetName = params.assetName,
newAssetUrl = params.assetUrl,
newVersionName = params.apkInfo.versionName,
newVersionCode = params.apkInfo.versionCode,
signingFingerprint = params.apkInfo.signingFingerprint,
)
installedAppsRepository.updatePendingStatus(packageName, params.isPendingInstall)
Comment on lines +122 to +133
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update the pending flag in the same persistence operation.

updateAppVersion(...) and updatePendingStatus(...) are separate writes here. If the second call fails, the record keeps the new version fields with the old pending flag, which directly breaks the install-outcome tracking this PR is adding. Please fold isPendingInstall into the same repository update/transaction as the version write.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt`
around lines 122 - 133, The current updateInstalledAppVersion suspends and calls
installedAppsRepository.updateAppVersion(...) and then
installedAppsRepository.updatePendingStatus(...), risking partial writes; change
this to perform a single repository update that includes the pending flag in the
same transaction: either add a new parameter (e.g., isPendingInstall) to
installedAppsRepository.updateAppVersion(...) and persist it there, or create a
new repository method (e.g., updateAppVersionWithPending(...) or
updateInstalledAppTransaction(...)) that atomically updates newTag,
newAssetName, newAssetUrl, newVersionName, newVersionCode, signingFingerprint
and isPendingInstall in one DB operation/transaction, then call that single
repository method from updateInstalledAppVersion.

}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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,
)
Loading