Skip to content

Commit 64dc2d5

Browse files
committed
feat: implement package and signing key verification for app linking
- Add validation logic to verify that the linked GitHub repository's APK matches the installed app's package name and signing fingerprint - Implement background verification process: check latest release, download APK, and extract signing info before linking - Update `AppsViewModel` to handle the verification workflow and provide real-time status updates - Enhance `LinkAppBottomSheet` UI to display validation status messages (e.g., "Checking latest release...", "Verifying signing key...") - Add new localized strings for mismatch errors and validation status steps - Ensure temporary APK files used for verification are deleted after the process completes
1 parent ec79ce7 commit 64dc2d5

4 files changed

Lines changed: 106 additions & 1 deletion

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,11 @@
550550
<string name="repo_url_hint">github.com/owner/repo</string>
551551
<string name="validating_repo">Validating…</string>
552552
<string name="link_and_track">Link &amp; Track</string>
553+
<string name="checking_release">Checking latest release…</string>
554+
<string name="downloading_for_verification">Downloading APK for verification…</string>
555+
<string name="verifying_signing_key">Verifying signing key…</string>
556+
<string name="package_name_mismatch">Package name mismatch: the APK is %1$s, but the selected app is %2$s</string>
557+
<string name="signing_key_mismatch_link">Signing key mismatch: the APK in this repository was signed by a different developer than the installed app</string>
553558
<string name="export_apps">Export</string>
554559
<string name="import_apps">Import</string>
555560
<string name="import_apps_title">Import apps</string>

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ data class AppsState(
2626
val repoUrl: String = "",
2727
val isValidatingRepo: Boolean = false,
2828
val repoValidationError: String? = null,
29+
val linkValidationStatus: String? = null,
2930
val fetchedRepoInfo: GithubRepoInfo? = null,
3031
// Export/Import
3132
val isExporting: Boolean = false,

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import zed.rainxch.apps.presentation.model.AppItem
2020
import zed.rainxch.apps.presentation.model.UpdateAllProgress
2121
import zed.rainxch.apps.presentation.model.UpdateState
2222
import zed.rainxch.core.domain.logging.GitHubStoreLogger
23+
import zed.rainxch.core.domain.model.DeviceApp
2324
import zed.rainxch.core.domain.model.InstalledApp
2425
import zed.rainxch.core.domain.model.RateLimitException
2526
import zed.rainxch.core.domain.network.Downloader
@@ -736,6 +737,7 @@ class AppsViewModel(
736737
selectedDeviceApp = null,
737738
repoUrl = "",
738739
repoValidationError = null,
740+
linkValidationStatus = null,
739741
fetchedRepoInfo = null,
740742
isValidatingRepo = false,
741743
)
@@ -753,25 +755,47 @@ class AppsViewModel(
753755
}
754756

755757
viewModelScope.launch {
756-
_state.update { it.copy(isValidatingRepo = true, repoValidationError = null) }
758+
_state.update {
759+
it.copy(
760+
isValidatingRepo = true,
761+
repoValidationError = null,
762+
linkValidationStatus = null,
763+
)
764+
}
757765

758766
try {
767+
_state.update { it.copy(linkValidationStatus = getString(Res.string.validating_repo)) }
768+
759769
val repoInfo = appsRepository.fetchRepoInfo(owner, repo)
760770
if (repoInfo == null) {
761771
_state.update {
762772
it.copy(
763773
isValidatingRepo = false,
774+
linkValidationStatus = null,
764775
repoValidationError = "Repository not found: $owner/$repo",
765776
)
766777
}
767778
return@launch
768779
}
769780

781+
val validationError = validateSigningFingerprint(selectedApp, owner, repo)
782+
if (validationError != null) {
783+
_state.update {
784+
it.copy(
785+
isValidatingRepo = false,
786+
linkValidationStatus = null,
787+
repoValidationError = validationError,
788+
)
789+
}
790+
return@launch
791+
}
792+
770793
appsRepository.linkAppToRepo(selectedApp, repoInfo)
771794

772795
_state.update {
773796
it.copy(
774797
isValidatingRepo = false,
798+
linkValidationStatus = null,
775799
showLinkSheet = false,
776800
)
777801
}
@@ -782,6 +806,7 @@ class AppsViewModel(
782806
_state.update {
783807
it.copy(
784808
isValidatingRepo = false,
809+
linkValidationStatus = null,
785810
repoValidationError = "GitHub API rate limit exceeded. Try again later.",
786811
)
787812
}
@@ -790,13 +815,75 @@ class AppsViewModel(
790815
_state.update {
791816
it.copy(
792817
isValidatingRepo = false,
818+
linkValidationStatus = null,
793819
repoValidationError = "Failed to link: ${e.message}",
794820
)
795821
}
796822
}
797823
}
798824
}
799825

826+
private suspend fun validateSigningFingerprint(
827+
deviceApp: DeviceApp,
828+
owner: String,
829+
repo: String,
830+
): String? {
831+
val latestRelease = try {
832+
_state.update { it.copy(linkValidationStatus = getString(Res.string.checking_release)) }
833+
appsRepository.getLatestRelease(owner, repo)
834+
} catch (e: RateLimitException) {
835+
throw e
836+
} catch (e: Exception) {
837+
logger.debug("Could not fetch release for validation: ${e.message}")
838+
return null
839+
}
840+
841+
if (latestRelease == null) return null
842+
843+
val installableAssets = latestRelease.assets.filter { installer.isAssetInstallable(it.name) }
844+
if (installableAssets.isEmpty()) return null
845+
846+
val asset = installer.choosePrimaryAsset(installableAssets) ?: return null
847+
848+
_state.update { it.copy(linkValidationStatus = getString(Res.string.downloading_for_verification)) }
849+
850+
var filePath: String? = null
851+
try {
852+
downloader.download(asset.downloadUrl, asset.name).collect { /* progress tracked by spinner */ }
853+
854+
filePath = downloader.getDownloadedFilePath(asset.name) ?: return null
855+
856+
_state.update { it.copy(linkValidationStatus = getString(Res.string.verifying_signing_key)) }
857+
858+
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
859+
if (apkInfo == null) {
860+
logger.debug("Could not extract APK info for validation")
861+
return null
862+
}
863+
864+
if (apkInfo.packageName != deviceApp.packageName) {
865+
return getString(
866+
Res.string.package_name_mismatch,
867+
apkInfo.packageName,
868+
deviceApp.packageName,
869+
)
870+
}
871+
872+
val deviceFingerprint = deviceApp.signingFingerprint
873+
val apkFingerprint = apkInfo.signingFingerprint
874+
875+
if (deviceFingerprint != null && apkFingerprint != null && deviceFingerprint != apkFingerprint) {
876+
return getString(Res.string.signing_key_mismatch_link)
877+
}
878+
879+
return null
880+
} finally {
881+
try {
882+
if (filePath != null) File(filePath).delete()
883+
} catch (_: Exception) { }
884+
}
885+
}
886+
800887
private fun parseGithubUrl(input: String): Pair<String, String>? {
801888
val cleaned =
802889
input

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ fun LinkAppBottomSheet(
8989
repoUrl = state.repoUrl,
9090
isValidating = state.isValidatingRepo,
9191
validationError = state.repoValidationError,
92+
validationStatus = state.linkValidationStatus,
9293
onUrlChanged = { onAction(AppsAction.OnRepoUrlChanged(it)) },
9394
onConfirm = { onAction(AppsAction.OnValidateAndLinkRepo) },
9495
onBack = { onAction(AppsAction.OnBackToAppPicker) },
@@ -239,6 +240,7 @@ private fun EnterUrlStep(
239240
repoUrl: String,
240241
isValidating: Boolean,
241242
validationError: String?,
243+
validationStatus: String?,
242244
onUrlChanged: (String) -> Unit,
243245
onConfirm: () -> Unit,
244246
onBack: () -> Unit,
@@ -338,5 +340,15 @@ private fun EnterUrlStep(
338340
)
339341
}
340342
}
343+
344+
if (isValidating && validationStatus != null) {
345+
Spacer(Modifier.height(8.dp))
346+
Text(
347+
text = validationStatus,
348+
style = MaterialTheme.typography.bodySmall,
349+
color = MaterialTheme.colorScheme.onSurfaceVariant,
350+
modifier = Modifier.fillMaxWidth(),
351+
)
352+
}
341353
}
342354
}

0 commit comments

Comments
 (0)