Skip to content

Commit 338c62a

Browse files
committed
feat: enhance app installation verification and attestation status
- Improve app attestation handling by introducing `VerificationResult` to distinguish between verified, unverified, and error states. - Update `DetailsViewModel` and `SmartInstallButton` to display a "Could not verify" warning when attestation checks fail. - Refactor `saveInstalledAppToDatabase` to accept pre-validated `ApkPackageInfo`, reducing redundant file parsing. - Update `InstalledAppsRepository` to handle `isPendingInstall` status directly within the version update flow. - Ensure the signing key warning state is cleared before proceeding with a manual override. - Enhance `VersionHelper.normalizeVersion` to specifically strip the `refs/tags/` prefix. - Add new localized string resource for unable to verify attestation status.
1 parent 8bd7261 commit 338c62a

11 files changed

Lines changed: 71 additions & 29 deletions

File tree

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ class InstalledAppsRepositoryImpl(
186186
newVersionName: String,
187187
newVersionCode: Long,
188188
signingFingerprint: String?,
189+
isPendingInstall: Boolean,
189190
) {
190191
val app = installedAppsDao.getAppByPackage(packageName) ?: return
191192

@@ -220,6 +221,7 @@ class InstalledAppsRepositoryImpl(
220221
latestVersionName = newVersionName,
221222
latestVersionCode = newVersionCode,
222223
isUpdateAvailable = false,
224+
isPendingInstall = isPendingInstall,
223225
lastUpdatedAt = System.currentTimeMillis(),
224226
lastCheckedAt = System.currentTimeMillis(),
225227
signingFingerprint = signingFingerprint,

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface InstalledAppsRepository {
3434
newVersionName: String,
3535
newVersionCode: Long,
3636
signingFingerprint: String?,
37+
isPendingInstall: Boolean = true,
3738
)
3839

3940
suspend fun updateApp(app: InstalledApp)

core/presentation/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@
210210
<string name="install_anyway">Install anyway</string>
211211
<string name="verified_build">Verified build</string>
212212
<string name="checking_attestation">Checking\u2026</string>
213+
<string name="unable_to_verify_attestation">Could not verify</string>
213214
<string name="install_version">Install %1$s</string>
214215
<string name="failed_to_open_app">Failed to open %1$s</string>
215216
<string name="failed_to_uninstall">Failed to uninstall %1$s</string>

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/AttestationVerifierImpl.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package zed.rainxch.details.data.system
33
import zed.rainxch.core.domain.logging.GitHubStoreLogger
44
import zed.rainxch.details.domain.repository.DetailsRepository
55
import zed.rainxch.details.domain.system.AttestationVerifier
6+
import zed.rainxch.details.domain.system.VerificationResult
67
import java.io.File
78
import java.io.FileInputStream
89
import java.security.MessageDigest
@@ -15,13 +16,14 @@ class AttestationVerifierImpl(
1516
owner: String,
1617
repoName: String,
1718
filePath: String,
18-
): Boolean =
19+
): VerificationResult =
1920
try {
2021
val digest = computeSha256(filePath)
21-
detailsRepository.checkAttestations(owner, repoName, digest)
22+
val hasAttestation = detailsRepository.checkAttestations(owner, repoName, digest)
23+
if (hasAttestation) VerificationResult.Verified else VerificationResult.Unverified
2224
} catch (e: Exception) {
2325
logger.debug("Attestation check error: ${e.message}")
24-
false
26+
VerificationResult.Error(e.message ?: "Unknown error")
2527
}
2628

2729
private fun computeSha256(filePath: String): String {

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,15 @@ class InstallationManagerImpl(
120120
}
121121

122122
override suspend fun updateInstalledAppVersion(params: UpdateInstalledAppParams) {
123-
val packageName = params.apkInfo.packageName
124123
installedAppsRepository.updateAppVersion(
125-
packageName = packageName,
124+
packageName = params.apkInfo.packageName,
126125
newTag = params.releaseTag,
127126
newAssetName = params.assetName,
128127
newAssetUrl = params.assetUrl,
129128
newVersionName = params.apkInfo.versionName,
130129
newVersionCode = params.apkInfo.versionCode,
131130
signingFingerprint = params.apkInfo.signingFingerprint,
131+
isPendingInstall = params.isPendingInstall,
132132
)
133-
installedAppsRepository.updatePendingStatus(packageName, params.isPendingInstall)
134133
}
135134
}

feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,21 @@ interface AttestationVerifier {
99
* Computes the SHA-256 digest of [filePath] and checks whether
1010
* the repository [owner]/[repoName] has a matching attestation.
1111
*
12-
* @return `true` if a valid attestation exists, `false` otherwise.
12+
* @return [VerificationResult.Verified] if a valid attestation exists,
13+
* [VerificationResult.Unverified] if no matching attestation was found,
14+
* [VerificationResult.Error] if the check could not be completed.
1315
*/
1416
suspend fun verify(
1517
owner: String,
1618
repoName: String,
1719
filePath: String,
18-
): Boolean
20+
): VerificationResult
21+
}
22+
23+
sealed interface VerificationResult {
24+
data object Verified : VerificationResult
25+
26+
data object Unverified : VerificationResult
27+
28+
data class Error(val reason: String) : VerificationResult
1929
}

feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import zed.rainxch.core.domain.model.GithubRelease
88
object VersionHelper {
99
fun normalizeVersion(version: String?): String =
1010
version
11+
?.trim()
12+
?.removePrefix("refs/tags/")
1113
?.removePrefix("v")
1214
?.removePrefix("V")
13-
?.trim()
1415
.orEmpty()
1516

1617
/**

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import kotlinx.datetime.format.char
2222
import kotlinx.datetime.toLocalDateTime
2323
import org.jetbrains.compose.resources.getString
2424
import zed.rainxch.core.domain.logging.GitHubStoreLogger
25+
import zed.rainxch.core.domain.model.ApkPackageInfo
2526
import zed.rainxch.core.domain.model.FavoriteRepo
2627
import zed.rainxch.core.domain.model.GithubAsset
2728
import zed.rainxch.core.domain.model.GithubRelease
@@ -39,14 +40,15 @@ import zed.rainxch.core.domain.system.PackageMonitor
3940
import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase
4041
import zed.rainxch.core.domain.utils.BrowserHelper
4142
import zed.rainxch.core.domain.utils.ShareManager
42-
import zed.rainxch.details.domain.model.ReleaseCategory
43-
import zed.rainxch.details.domain.repository.DetailsRepository
44-
import zed.rainxch.details.domain.repository.TranslationRepository
4543
import zed.rainxch.details.domain.model.ApkValidationResult
4644
import zed.rainxch.details.domain.model.FingerprintCheckResult
45+
import zed.rainxch.details.domain.model.ReleaseCategory
4746
import zed.rainxch.details.domain.model.SaveInstalledAppParams
4847
import zed.rainxch.details.domain.model.UpdateInstalledAppParams
48+
import zed.rainxch.details.domain.repository.DetailsRepository
49+
import zed.rainxch.details.domain.repository.TranslationRepository
4950
import zed.rainxch.details.domain.system.AttestationVerifier
51+
import zed.rainxch.details.domain.system.VerificationResult
5052
import zed.rainxch.details.domain.system.InstallationManager
5153
import zed.rainxch.details.domain.util.VersionHelper
5254
import zed.rainxch.details.presentation.model.AttestationStatus
@@ -859,6 +861,7 @@ class DetailsViewModel(
859861

860862
private fun overrideSigningKeyWarning() {
861863
val warning = _state.value.signingKeyWarning ?: return
864+
_state.update { it.copy(signingKeyWarning = null) }
862865
dismissDowngradeWarning()
863866
viewModelScope.launch {
864867
try {
@@ -867,12 +870,12 @@ class DetailsViewModel(
867870

868871
if (platform == Platform.ANDROID) {
869872
saveInstalledAppToDatabase(
873+
apkInfo = warning.pendingApkInfo,
870874
assetName = warning.pendingAssetName,
871875
assetUrl = warning.pendingDownloadUrl,
872876
assetSize = warning.pendingSizeBytes,
873877
releaseTag = warning.pendingReleaseTag,
874878
isUpdate = warning.pendingIsUpdate,
875-
filePath = warning.pendingFilePath,
876879
installOutcome = installOutcome,
877880
)
878881
}
@@ -966,6 +969,7 @@ class DetailsViewModel(
966969

967970
val ext = assetName.substringAfterLast('.', "").lowercase()
968971
val isApk = ext == "apk"
972+
var validatedApkInfo: ApkPackageInfo? = null
969973

970974
if (isApk) {
971975
val validationResult =
@@ -1020,6 +1024,7 @@ class DetailsViewModel(
10201024
}
10211025

10221026
is ApkValidationResult.Valid -> {
1027+
validatedApkInfo = validationResult.apkInfo
10231028
val fpResult =
10241029
installationManager.checkSigningFingerprint(validationResult.apkInfo)
10251030
if (fpResult is FingerprintCheckResult.Mismatch) {
@@ -1036,6 +1041,7 @@ class DetailsViewModel(
10361041
pendingReleaseTag = releaseTag,
10371042
pendingIsUpdate = isUpdate,
10381043
pendingFilePath = filePath,
1044+
pendingApkInfo = validationResult.apkInfo,
10391045
),
10401046
)
10411047
}
@@ -1056,17 +1062,17 @@ class DetailsViewModel(
10561062
// Launch attestation check asynchronously (non-blocking)
10571063
launchAttestationCheck(filePath)
10581064

1059-
if (platform == Platform.ANDROID) {
1065+
if (platform == Platform.ANDROID && validatedApkInfo != null) {
10601066
saveInstalledAppToDatabase(
1067+
apkInfo = validatedApkInfo,
10611068
assetName = assetName,
10621069
assetUrl = downloadUrl,
10631070
assetSize = sizeBytes,
10641071
releaseTag = releaseTag,
10651072
isUpdate = isUpdate,
1066-
filePath = filePath,
10671073
installOutcome = installOutcome,
10681074
)
1069-
} else {
1075+
} else if (platform != Platform.ANDROID) {
10701076
viewModelScope.launch {
10711077
_events.send(DetailsEvent.OnMessage(getString(Res.string.installer_saved_downloads)))
10721078
}
@@ -1095,11 +1101,14 @@ class DetailsViewModel(
10951101
_state.update { it.copy(attestationStatus = AttestationStatus.CHECKING) }
10961102

10971103
viewModelScope.launch {
1098-
val verified = attestationVerifier.verify(owner, repoName, filePath)
1104+
val result = attestationVerifier.verify(owner, repoName, filePath)
10991105
_state.update {
11001106
it.copy(
1101-
attestationStatus =
1102-
if (verified) AttestationStatus.VERIFIED else AttestationStatus.UNVERIFIED,
1107+
attestationStatus = when (result) {
1108+
is VerificationResult.Verified -> AttestationStatus.VERIFIED
1109+
is VerificationResult.Unverified -> AttestationStatus.UNVERIFIED
1110+
is VerificationResult.Error -> AttestationStatus.UNABLE_TO_VERIFY
1111+
},
11031112
)
11041113
}
11051114
}
@@ -1209,22 +1218,15 @@ class DetailsViewModel(
12091218
}
12101219

12111220
private suspend fun saveInstalledAppToDatabase(
1221+
apkInfo: ApkPackageInfo,
12121222
assetName: String,
12131223
assetUrl: String,
12141224
assetSize: Long,
12151225
releaseTag: String,
12161226
isUpdate: Boolean,
1217-
filePath: String,
1218-
installOutcome: InstallOutcome = InstallOutcome.DELEGATED_TO_SYSTEM,
1227+
installOutcome: InstallOutcome,
12191228
) {
12201229
val repo = _state.value.repository ?: return
1221-
if (platform != Platform.ANDROID || !assetName.lowercase().endsWith(".apk")) return
1222-
1223-
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
1224-
if (apkInfo == null) {
1225-
logger.error("Failed to extract APK info for $assetName")
1226-
return
1227-
}
12281230

12291231
if (isUpdate) {
12301232
installationManager.updateInstalledAppVersion(

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Delete
2525
import androidx.compose.material.icons.filled.KeyboardArrowDown
2626
import androidx.compose.material.icons.filled.Update
2727
import androidx.compose.material.icons.filled.VerifiedUser
28+
import androidx.compose.material.icons.outlined.Warning
2829
import androidx.compose.material3.CardDefaults
2930
import androidx.compose.material3.CircularProgressIndicator
3031
import androidx.compose.material3.ElevatedCard
@@ -65,6 +66,7 @@ import zed.rainxch.githubstore.core.presentation.res.installing
6566
import zed.rainxch.githubstore.core.presentation.res.not_available
6667
import zed.rainxch.githubstore.core.presentation.res.open_app
6768
import zed.rainxch.githubstore.core.presentation.res.show_install_options
69+
import zed.rainxch.githubstore.core.presentation.res.unable_to_verify_attestation
6870
import zed.rainxch.githubstore.core.presentation.res.uninstall
6971
import zed.rainxch.githubstore.core.presentation.res.update_to_version
7072
import zed.rainxch.githubstore.core.presentation.res.updating
@@ -569,7 +571,10 @@ fun SmartInstallButton(
569571
@Composable
570572
private fun AttestationBadge(attestationStatus: AttestationStatus) {
571573
AnimatedVisibility(
572-
visible = attestationStatus == AttestationStatus.VERIFIED || attestationStatus == AttestationStatus.CHECKING,
574+
visible =
575+
attestationStatus == AttestationStatus.VERIFIED ||
576+
attestationStatus == AttestationStatus.CHECKING ||
577+
attestationStatus == AttestationStatus.UNABLE_TO_VERIFY,
573578
enter = fadeIn(),
574579
exit = fadeOut(),
575580
) {
@@ -609,6 +614,21 @@ private fun AttestationBadge(attestationStatus: AttestationStatus) {
609614
)
610615
}
611616

617+
AttestationStatus.UNABLE_TO_VERIFY -> {
618+
Icon(
619+
imageVector = Icons.Outlined.Warning,
620+
contentDescription = null,
621+
modifier = Modifier.size(14.dp),
622+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
623+
)
624+
Spacer(modifier = Modifier.width(4.dp))
625+
Text(
626+
text = stringResource(Res.string.unable_to_verify_attestation),
627+
style = MaterialTheme.typography.labelSmall,
628+
color = MaterialTheme.colorScheme.onSurfaceVariant,
629+
)
630+
}
631+
612632
else -> {}
613633
}
614634
}

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/model/AttestationStatus.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ enum class AttestationStatus {
55
CHECKING,
66
VERIFIED,
77
UNVERIFIED,
8+
UNABLE_TO_VERIFY,
89
}

0 commit comments

Comments
 (0)