From 0d52d87acb054c19f5f5edeecfc6e8951168d82b Mon Sep 17 00:00:00 2001 From: lucky Date: Fri, 15 May 2026 14:42:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(apk):=20APK=20Inspector=20=E2=80=94=20anal?= =?UTF-8?q?yze=20APKs=20without=20installing=20+=20auto=20assetlinks=20che?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new sidebar screen that takes a local .apk, parses the manifest and signing certificates in-process, and (the headline value) cross-references every autoVerify https domain against the live assetlinks.json — answering "would this build verify on a real device?" without needing a device at all. Domain - ApkAnalysisResult / ApkInfo / ApkSignature / ApkIntentFilter / ApkLinkDomain / ApkPathPattern / ApkDomainVerification / ApkFingerprintMatch (sealed) - collapseLinkDomains merges per-filter declarations into one entry per host so the user sees the same domain list Android's verifier would consider Infrastructure - net.dongliu:apk-parser 2.6.10 wraps APK + manifest binary XML decoding + signing block parsing. Uses getAllCertificateMetas() to union v1/v2/v3 schemes. - Fingerprints are SHA-256-hashed locally from raw cert bytes (apk-parser doesn't expose them directly), then formatted as XX:XX:XX:... uppercase to match the canonical form assetlinks.json uses - Manifest XML is walked via DOM (DocumentBuilder) — explicitly handles all 5 component tags (activity, activity-alias, service, receiver, provider) and every attribute variant (path/pathPrefix/pathSuffix/pathPattern/ pathAdvancedPattern + scheme + host + mimeType) - Filters with no link-relevant data (boot completed, sync, push) are dropped rather than cluttering the result panel UseCase - AnalyzeApkAndValidateLinksUseCase fans out to assetlinks for every autoVerify https domain in parallel, then per-domain matches the APK's SHA-256 fingerprints against the JSON's. Result: FullMatch / PartialMatch / NoMatch / PackageNotDeclared / AssetLinksUnavailable UI - New sidebar item "APK" (Android icon) routes to ApkInspectorScreen - AWT FileDialog for native picker on macOS / Windows / Linux - Result cards: Package metadata, Signing certs, App Links verification (the headline section), All declared link domains, Intent filters (collapsible — large APKs can have 50+) - AAB rejected up-front with a clear "use the universal/split APK" hint — silently failing on .aab would mislead users Test plan documented in PR body. Closes #32. --- composeApp/build.gradle.kts | 1 + .../jvmMain/kotlin/com/manjee/linkops/App.kt | 16 + .../repository/ApkAnalysisRepositoryImpl.kt | 23 + .../com/manjee/linkops/di/AppContainer.kt | 23 + .../linkops/domain/model/ApkAnalysis.kt | 113 ++++ .../repository/ApkAnalysisRepository.kt | 14 + .../apk/AnalyzeApkAndValidateLinksUseCase.kt | 107 ++++ .../domain/usecase/apk/AnalyzeApkUseCase.kt | 13 + .../linkops/infrastructure/apk/ApkAnalyzer.kt | 231 ++++++++ .../manjee/linkops/ui/navigation/NavGraph.kt | 1 + .../manjee/linkops/ui/navigation/Screen.kt | 6 + .../ui/screen/apk/ApkInspectorScreen.kt | 534 ++++++++++++++++++ .../ui/screen/apk/ApkInspectorViewModel.kt | 117 ++++ gradle/libs.versions.toml | 2 + 14 files changed, 1201 insertions(+) create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/ApkAnalysisRepositoryImpl.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/ApkAnalysis.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/ApkAnalysisRepository.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkAndValidateLinksUseCase.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkUseCase.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/apk/ApkAnalyzer.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorScreen.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorViewModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f3eb3a4..547c3ed 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -35,6 +35,7 @@ kotlin { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.zxing.core) + implementation(libs.apk.parser) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) implementation(libs.ktor.server.content.negotiation) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt index 85e008b..86e2c59 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt @@ -3,6 +3,7 @@ package com.manjee.linkops import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Home @@ -38,6 +39,8 @@ import com.manjee.linkops.ui.screen.localhosting.LocalHostingScreen import com.manjee.linkops.ui.screen.localhosting.LocalHostingViewModel import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerScreen import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerViewModel +import com.manjee.linkops.ui.screen.apk.ApkInspectorScreen +import com.manjee.linkops.ui.screen.apk.ApkInspectorViewModel import com.manjee.linkops.ui.screen.settings.SettingsScreen import com.manjee.linkops.ui.screen.settings.SettingsViewModel import com.manjee.linkops.ui.screen.sniffer.IntentSnifferScreen @@ -74,6 +77,7 @@ fun App() { val batchTestViewModel = remember { BatchTestViewModel() } val localHostingViewModel = remember { LocalHostingViewModel() } val intentSnifferViewModel = remember { IntentSnifferViewModel() } + val apkInspectorViewModel = remember { ApkInspectorViewModel() } val settingsViewModel = remember { SettingsViewModel() } val keyboardShortcutHandler = remember { KeyboardShortcutHandler() } val searchFocusTrigger = remember { mutableStateOf(0) } @@ -97,6 +101,7 @@ fun App() { batchTestViewModel.onCleared() localHostingViewModel.onCleared() intentSnifferViewModel.onCleared() + apkInspectorViewModel.onCleared() settingsViewModel.onCleared() } } @@ -219,6 +224,10 @@ fun App() { ) } + Screen.ApkInspector -> { + ApkInspectorScreen(viewModel = apkInspectorViewModel) + } + Screen.Settings -> { SettingsScreen(viewModel = settingsViewModel) } @@ -314,6 +323,13 @@ private fun NavigationSidebar( onClick = { onNavigate(Screen.IntentSniffer) } ) + NavigationRailItem( + icon = { Icon(Icons.Default.Android, contentDescription = "APK") }, + label = { Text("APK") }, + selected = currentScreen == Screen.ApkInspector, + onClick = { onNavigate(Screen.ApkInspector) } + ) + Spacer(modifier = Modifier.weight(1f)) NavigationRailItem( diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/ApkAnalysisRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/ApkAnalysisRepositoryImpl.kt new file mode 100644 index 0000000..7b2a8a5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/ApkAnalysisRepositoryImpl.kt @@ -0,0 +1,23 @@ +package com.manjee.linkops.data.repository + +import com.manjee.linkops.domain.model.ApkAnalysisResult +import com.manjee.linkops.domain.repository.ApkAnalysisRepository +import com.manjee.linkops.infrastructure.apk.ApkAnalyzer +import java.io.File + +class ApkAnalysisRepositoryImpl( + private val analyzer: ApkAnalyzer +) : ApkAnalysisRepository { + + override suspend fun analyzeApk(apkFile: File): Result { + return analyzer.analyze(apkFile).map { analyzed -> + ApkAnalysisResult( + info = analyzed.info, + signatures = analyzed.signatures, + intentFilters = analyzed.intentFilters, + linkDomains = analyzed.linkDomains, + domainVerifications = emptyList() + ) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt index 8188a2d..39d7813 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -17,6 +17,7 @@ import com.manjee.linkops.data.parser.IntentPayloadParser import com.manjee.linkops.data.parser.LogcatParser import com.manjee.linkops.data.parser.ManifestParser import com.manjee.linkops.data.repository.AasaRepositoryImpl +import com.manjee.linkops.data.repository.ApkAnalysisRepositoryImpl import com.manjee.linkops.data.repository.AppLinkRepositoryImpl import com.manjee.linkops.data.repository.AssetLinksRepositoryImpl import com.manjee.linkops.data.repository.BatchTestRepositoryImpl @@ -31,6 +32,7 @@ import com.manjee.linkops.data.repository.SettingsRepositoryImpl import com.manjee.linkops.data.repository.VerificationDiagnosticsRepositoryImpl import com.manjee.linkops.data.strategy.AdbCommandStrategyFactory import com.manjee.linkops.domain.repository.AasaRepository +import com.manjee.linkops.domain.repository.ApkAnalysisRepository import com.manjee.linkops.domain.repository.AppLinkRepository import com.manjee.linkops.domain.repository.AssetLinksRepository import com.manjee.linkops.domain.repository.BatchTestRepository @@ -43,6 +45,8 @@ import com.manjee.linkops.domain.repository.LogStreamRepository import com.manjee.linkops.domain.repository.ManifestRepository import com.manjee.linkops.domain.repository.SettingsRepository import com.manjee.linkops.domain.repository.VerificationDiagnosticsRepository +import com.manjee.linkops.domain.usecase.apk.AnalyzeApkAndValidateLinksUseCase +import com.manjee.linkops.domain.usecase.apk.AnalyzeApkUseCase import com.manjee.linkops.domain.usecase.applink.FireIntentUseCase import com.manjee.linkops.domain.usecase.applink.ForceReverifyUseCase import com.manjee.linkops.domain.usecase.applink.GetAppLinksUseCase @@ -77,6 +81,7 @@ import com.manjee.linkops.domain.usecase.manifest.TestDeepLinkUseCase import com.manjee.linkops.domain.model.IntentFiredEvent import com.manjee.linkops.infrastructure.adb.AdbBinaryManager import com.manjee.linkops.infrastructure.adb.AdbShellExecutor +import com.manjee.linkops.infrastructure.apk.ApkAnalyzer import com.manjee.linkops.infrastructure.network.AasaClient import com.manjee.linkops.infrastructure.network.AssetLinksClient import com.manjee.linkops.infrastructure.qr.QrCodeGenerator @@ -140,6 +145,11 @@ object AppContainer { AasaParser() } + // Infrastructure - APK + private val apkAnalyzer: ApkAnalyzer by lazy { + ApkAnalyzer() + } + private val manifestParser: ManifestParser by lazy { ManifestParser() } @@ -213,6 +223,10 @@ object AppContainer { AasaRepositoryImpl(aasaClient, aasaParser) } + val apkAnalysisRepository: ApkAnalysisRepository by lazy { + ApkAnalysisRepositoryImpl(apkAnalyzer) + } + val manifestRepository: ManifestRepository by lazy { ManifestRepositoryImpl(adbShellExecutor, manifestParser) } @@ -294,6 +308,15 @@ object AppContainer { ValidateAasaUseCase(aasaRepository) } + // UseCases - APK + val analyzeApkUseCase: AnalyzeApkUseCase by lazy { + AnalyzeApkUseCase(apkAnalysisRepository) + } + + val analyzeApkAndValidateLinksUseCase: AnalyzeApkAndValidateLinksUseCase by lazy { + AnalyzeApkAndValidateLinksUseCase(apkAnalysisRepository, assetLinksRepository) + } + val analyzeVerificationUseCase: AnalyzeVerificationUseCase by lazy { AnalyzeVerificationUseCase(verificationDiagnosticsRepository) } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/ApkAnalysis.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/ApkAnalysis.kt new file mode 100644 index 0000000..397c41e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/ApkAnalysis.kt @@ -0,0 +1,113 @@ +package com.manjee.linkops.domain.model + +/** + * Top-level result of inspecting an APK file. Optionally includes the + * cross-reference of each autoVerify domain against the live `assetlinks.json` + * we fetched (when the user opted in to that — which is the default). + */ +data class ApkAnalysisResult( + val info: ApkInfo, + val signatures: List, + val intentFilters: List, + val linkDomains: List, + val domainVerifications: List = emptyList() +) + +/** + * Identifying metadata pulled from the APK manifest. + */ +data class ApkInfo( + val filePath: String, + val fileSizeBytes: Long, + val packageName: String, + val versionName: String?, + val versionCode: Long?, + val minSdk: Int?, + val targetSdk: Int?, + val applicationLabel: String? +) + +/** + * One signing certificate. APKs can ship multiple (v1/v2/v3 schemes, debug + release + * key rotation, etc.) so we surface them all rather than picking one — the user needs + * to know which one Android will actually use to compare against `assetlinks.json`. + */ +data class ApkSignature( + val sha256Fingerprint: String, + val sha1Fingerprint: String?, + val subjectDn: String?, + val issuerDn: String?, + val validFrom: Long?, + val validTo: Long? +) + +/** + * One declared in the manifest. Distinct from a domain — a single + * filter can cover multiple schemes/hosts/paths simultaneously, so we keep the + * lists rather than flattening prematurely. + */ +data class ApkIntentFilter( + val componentName: String, + val actions: List, + val categories: List, + val schemes: List, + val hosts: List, + val pathPatterns: List, + val autoVerify: Boolean, + val mimeTypes: List = emptyList() +) + +data class ApkPathPattern( + val pattern: String, + val type: PatternType +) { + enum class PatternType { LITERAL, PREFIX, SUFFIX, GLOB, ADVANCED_GLOB } +} + +/** + * A normalized, deduplicated link domain extracted from intent filters. This is what + * we feed into `assetlinks.json` validation — one entry per (host, autoVerify) pair, + * scheme set merged. + */ +data class ApkLinkDomain( + val host: String, + val schemes: Set, + val autoVerify: Boolean +) { + val isHttps: Boolean get() = "https" in schemes + val isAppLinkCandidate: Boolean get() = autoVerify && isHttps +} + +/** + * Result of cross-checking one of the APK's autoVerify domains against the + * `assetlinks.json` actually hosted at that domain. This is the headline value + * of APK analysis — answers "would this build verify on a real device?" + */ +data class ApkDomainVerification( + val domain: String, + val assetLinksValidation: AssetLinksValidation, + val fingerprintMatch: ApkFingerprintMatch +) + +sealed class ApkFingerprintMatch { + /** assetlinks.json contains every signature this APK ships with. */ + data object FullMatch : ApkFingerprintMatch() + + /** assetlinks.json contains at least one but not all signatures. */ + data class PartialMatch( + val matchedFingerprints: List, + val unmatchedFingerprints: List + ) : ApkFingerprintMatch() + + /** assetlinks.json contains the package but none of this APK's signatures match. */ + data class NoMatch( + val apkFingerprints: List, + val assetLinksFingerprints: List + ) : ApkFingerprintMatch() + + /** assetlinks.json doesn't even mention this package. */ + data object PackageNotDeclared : ApkFingerprintMatch() + + /** assetlinks.json couldn't be fetched / parsed — fingerprint check skipped. */ + data object AssetLinksUnavailable : ApkFingerprintMatch() +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/ApkAnalysisRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/ApkAnalysisRepository.kt new file mode 100644 index 0000000..7c9f831 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/ApkAnalysisRepository.kt @@ -0,0 +1,14 @@ +package com.manjee.linkops.domain.repository + +import com.manjee.linkops.domain.model.ApkAnalysisResult +import java.io.File + +interface ApkAnalysisRepository { + /** + * Parses [apkFile] in-process — no installation, no ADB. The result includes + * every signing certificate, every intent-filter, and a deduplicated list of + * link domains; it does NOT include assetlinks cross-checking (see the use case + * that pairs this with `ValidateAssetLinksUseCase`). + */ + suspend fun analyzeApk(apkFile: File): Result +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkAndValidateLinksUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkAndValidateLinksUseCase.kt new file mode 100644 index 0000000..50ecafb --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkAndValidateLinksUseCase.kt @@ -0,0 +1,107 @@ +package com.manjee.linkops.domain.usecase.apk + +import com.manjee.linkops.domain.model.ApkAnalysisResult +import com.manjee.linkops.domain.model.ApkDomainVerification +import com.manjee.linkops.domain.model.ApkFingerprintMatch +import com.manjee.linkops.domain.model.ApkLinkDomain +import com.manjee.linkops.domain.model.ApkSignature +import com.manjee.linkops.domain.model.AssetLinksContent +import com.manjee.linkops.domain.repository.ApkAnalysisRepository +import com.manjee.linkops.domain.repository.AssetLinksRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.io.File + +/** + * The headline use case for APK Inspector. Pulls the manifest + signatures out of + * the APK and then, for every host the APK marks as autoVerify https, fetches the + * live `assetlinks.json` and checks whether the APK's signing fingerprints actually + * appear there — answering "would this build verify on a real device?" without + * needing a device at all. + * + * Domains are validated in parallel because most of the time is spent waiting on + * remote HTTPS round-trips, and they're independent of each other. + */ +class AnalyzeApkAndValidateLinksUseCase( + private val apkAnalysisRepository: ApkAnalysisRepository, + private val assetLinksRepository: AssetLinksRepository +) { + suspend operator fun invoke(apkFile: File): Result = runCatching { + val analysis = apkAnalysisRepository.analyzeApk(apkFile).getOrThrow() + val verifications = verifyAutoVerifyDomains(analysis.linkDomains, analysis.signatures) + analysis.copy(domainVerifications = verifications) + } + + private suspend fun verifyAutoVerifyDomains( + domains: List, + signatures: List + ): List = coroutineScope { + val candidates = domains.filter { it.isAppLinkCandidate } + if (candidates.isEmpty()) return@coroutineScope emptyList() + + val apkFingerprints = signatures.map { it.sha256Fingerprint } + + candidates + .map { domain -> + async { verifyOne(domain, apkFingerprints) } + } + .map { it.await() } + } + + private suspend fun verifyOne( + domain: ApkLinkDomain, + apkFingerprints: List + ): ApkDomainVerification { + val validation = assetLinksRepository.validateAssetLinks(domain.host).getOrNull() + val content = validation?.content + val match = computeMatch(content, apkFingerprints, packageName = null) + + return ApkDomainVerification( + domain = domain.host, + assetLinksValidation = validation + ?: error("Repository returned null without throwing — should not happen"), + fingerprintMatch = match + ) + } + + /** + * @param packageName intentionally unused for now — we match purely on the SHA-256 + * fingerprint because that's what the live verifier on Android actually checks. + * If we later want to also assert the package name lines up across all statements + * we can wire it in here without touching call sites. + */ + private fun computeMatch( + content: AssetLinksContent?, + apkFingerprints: List, + @Suppress("UNUSED_PARAMETER") packageName: String? + ): ApkFingerprintMatch { + if (content == null) return ApkFingerprintMatch.AssetLinksUnavailable + + val assetLinksFps = content.statements + .flatMap { it.target.sha256CertFingerprints } + .map { normalize(it) } + .distinct() + + if (assetLinksFps.isEmpty()) return ApkFingerprintMatch.PackageNotDeclared + + val normalizedApk = apkFingerprints.map { normalize(it) } + val matched = normalizedApk.filter { it in assetLinksFps } + val unmatched = normalizedApk.filterNot { it in assetLinksFps } + + return when { + matched.size == normalizedApk.size -> ApkFingerprintMatch.FullMatch + matched.isNotEmpty() -> ApkFingerprintMatch.PartialMatch(matched, unmatched) + else -> ApkFingerprintMatch.NoMatch( + apkFingerprints = normalizedApk, + assetLinksFingerprints = assetLinksFps + ) + } + } + + /** + * Strip colons / whitespace / case so apkFingerprints (XX:XX:XX...) and + * assetlinks fingerprints (often lowercase, sometimes no colons) compare equal. + */ + private fun normalize(fp: String): String = + fp.replace(":", "").replace(" ", "").uppercase() +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkUseCase.kt new file mode 100644 index 0000000..901efda --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/apk/AnalyzeApkUseCase.kt @@ -0,0 +1,13 @@ +package com.manjee.linkops.domain.usecase.apk + +import com.manjee.linkops.domain.model.ApkAnalysisResult +import com.manjee.linkops.domain.repository.ApkAnalysisRepository +import java.io.File + +class AnalyzeApkUseCase( + private val apkAnalysisRepository: ApkAnalysisRepository +) { + suspend operator fun invoke(apkFile: File): Result { + return apkAnalysisRepository.analyzeApk(apkFile) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/apk/ApkAnalyzer.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/apk/ApkAnalyzer.kt new file mode 100644 index 0000000..c340142 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/apk/ApkAnalyzer.kt @@ -0,0 +1,231 @@ +package com.manjee.linkops.infrastructure.apk + +import com.manjee.linkops.domain.model.ApkInfo +import com.manjee.linkops.domain.model.ApkIntentFilter +import com.manjee.linkops.domain.model.ApkLinkDomain +import com.manjee.linkops.domain.model.ApkPathPattern +import com.manjee.linkops.domain.model.ApkSignature +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.dongliu.apk.parser.ApkFile +import net.dongliu.apk.parser.bean.CertificateMeta +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import java.io.ByteArrayInputStream +import java.io.File +import java.security.MessageDigest +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Loads an APK off disk and pulls out the bits we care about for deep-link debugging: + * package metadata, signing certificates, intent-filters, and a deduplicated list of + * link domains worth checking against `assetlinks.json`. + * + * Wraps `net.dongliu:apk-parser` so the rest of the app never sees that library — + * if we ever need to swap it (for AAB support, for tighter APK Signature Scheme v3 + * handling, etc.) the change stays inside this file. + * + * All work runs on Dispatchers.IO. APKs can be hundreds of MB and the manifest XML + * parse alone is non-trivial. + */ +class ApkAnalyzer { + + suspend fun analyze(file: File): Result = withContext(Dispatchers.IO) { + runCatching { + require(file.exists()) { "APK file not found: ${file.absolutePath}" } + require(file.isFile) { "Path is not a file: ${file.absolutePath}" } + require(file.length() > 0) { "APK file is empty: ${file.absolutePath}" } + + ApkFile(file).use { apk -> + val meta = apk.apkMeta + val info = ApkInfo( + filePath = file.absolutePath, + fileSizeBytes = file.length(), + packageName = meta.packageName, + versionName = meta.versionName, + versionCode = meta.versionCode, + minSdk = meta.minSdkVersion?.toIntOrNull(), + targetSdk = meta.targetSdkVersion?.toIntOrNull(), + applicationLabel = meta.label + ) + + val signatures = extractSignatures(apk) + val intentFilters = extractIntentFilters(apk.manifestXml) + val linkDomains = collapseLinkDomains(intentFilters) + + AnalyzedApk(info, signatures, intentFilters, linkDomains) + } + } + } + + private fun extractSignatures(apk: ApkFile): List { + // `getAllCertificateMetas()` returns v1 + v2 + v3 metadata grouped by the + // signing block path. APKs commonly ship multiple schemes for compatibility, + // so we flatten and dedup by sha256. The library only hands back raw + // certificate bytes, so SHA-256/SHA-1 are computed locally — also keeps us + // tolerant of library versions that omit certificate helper getters. + val metas: List = runCatching { apk.allCertificateMetas } + .getOrNull() + ?.values + ?.flatten() + ?: emptyList() + + return metas + .mapNotNull { meta -> + val der = meta.data ?: return@mapNotNull null + val sha256 = digestHex(der, "SHA-256") ?: return@mapNotNull null + ApkSignature( + sha256Fingerprint = formatFingerprint(sha256), + sha1Fingerprint = digestHex(der, "SHA-1")?.let(::formatFingerprint), + subjectDn = meta.signAlgorithm, + issuerDn = null, + validFrom = meta.startDate?.time, + validTo = meta.endDate?.time + ) + } + .distinctBy { it.sha256Fingerprint } + } + + private fun digestHex(data: ByteArray, algorithm: String): String? = runCatching { + MessageDigest.getInstance(algorithm) + .digest(data) + .joinToString("") { "%02X".format(it) } + }.getOrNull() + + private fun formatFingerprint(uppercaseHex: String): String = + uppercaseHex.chunked(2).joinToString(":") + + private fun extractIntentFilters(manifestXml: String): List { + if (manifestXml.isBlank()) return emptyList() + + val doc = DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = true } + .newDocumentBuilder() + .parse(ByteArrayInputStream(manifestXml.toByteArray(Charsets.UTF_8))) + + val results = mutableListOf() + + // Walk all , , , , — + // any of them can declare an intent-filter that matters for deep links, + // though in practice activities cover ~99% of cases. + val componentTags = listOf("activity", "activity-alias", "service", "receiver", "provider") + componentTags.forEach { tag -> + val components = doc.getElementsByTagName(tag) + for (i in 0 until components.length) { + val component = components.item(i) as? Element ?: continue + val componentName = component.getAttr("name") ?: "unknown" + + val filters = component.childElements("intent-filter") + filters.forEach { filter -> + parseFilter(filter, componentName)?.let(results::add) + } + } + } + + return results + } + + private fun parseFilter(filter: Element, componentName: String): ApkIntentFilter? { + val actions = filter.childElements("action").mapNotNull { it.getAttr("name") } + val categories = filter.childElements("category").mapNotNull { it.getAttr("name") } + val dataNodes = filter.childElements("data") + + val schemes = dataNodes.mapNotNull { it.getAttr("scheme") }.distinct() + val hosts = dataNodes.mapNotNull { it.getAttr("host") }.distinct() + val mimeTypes = dataNodes.mapNotNull { it.getAttr("mimeType") }.distinct() + + val pathPatterns = buildList { + dataNodes.forEach { node -> + node.getAttr("path")?.let { add(ApkPathPattern(it, ApkPathPattern.PatternType.LITERAL)) } + node.getAttr("pathPrefix")?.let { add(ApkPathPattern(it, ApkPathPattern.PatternType.PREFIX)) } + node.getAttr("pathSuffix")?.let { add(ApkPathPattern(it, ApkPathPattern.PatternType.SUFFIX)) } + node.getAttr("pathPattern")?.let { add(ApkPathPattern(it, ApkPathPattern.PatternType.GLOB)) } + node.getAttr("pathAdvancedPattern")?.let { + add(ApkPathPattern(it, ApkPathPattern.PatternType.ADVANCED_GLOB)) + } + } + } + + // Drop filters that have no link-relevant data — they're for non-URI intents + // (boot completed, sync, push, etc.) and would clutter the result panel. + if (schemes.isEmpty() && hosts.isEmpty() && pathPatterns.isEmpty() && mimeTypes.isEmpty()) { + return null + } + + val autoVerify = filter.getAttr("autoVerify")?.equals("true", ignoreCase = true) ?: false + + return ApkIntentFilter( + componentName = componentName, + actions = actions, + categories = categories, + schemes = schemes, + hosts = hosts, + pathPatterns = pathPatterns, + autoVerify = autoVerify, + mimeTypes = mimeTypes + ) + } + + /** + * Collapse the per-filter view into one entry per host, merging schemes and + * propagating autoVerify=true if any filter for that host declared it. This + * matches how Android decides whether a host is an App Link candidate. + */ + private fun collapseLinkDomains(filters: List): List { + val byHost = mutableMapOf() + filters.forEach { filter -> + filter.hosts.forEach { host -> + val existing = byHost[host] + byHost[host] = if (existing == null) { + ApkLinkDomain( + host = host, + schemes = filter.schemes.toSet(), + autoVerify = filter.autoVerify + ) + } else { + existing.copy( + schemes = existing.schemes + filter.schemes, + autoVerify = existing.autoVerify || filter.autoVerify + ) + } + } + } + return byHost.values.sortedBy { it.host } + } + + private fun Element.getAttr(localName: String): String? { + // Manifest namespace is `http://schemas.android.com/apk/res/android` for `android:*`; + // try namespaced first, fall back to bare for the rare manifest that omits it. + val nsValue = getAttributeNS("http://schemas.android.com/apk/res/android", localName) + if (nsValue.isNotBlank()) return nsValue + val plain = getAttribute(localName) + return plain.takeIf { it.isNotBlank() } + } + + private fun Element.childElements(tagName: String): List { + val result = mutableListOf() + val children: NodeList = childNodes + for (i in 0 until children.length) { + val node = children.item(i) + if (node.nodeType == Node.ELEMENT_NODE) { + val element = node as Element + if (element.localName == tagName || element.tagName == tagName) { + result.add(element) + } + } + } + return result + } +} + +/** + * Raw analyzer output. The repository wraps this with optional assetlinks + * cross-referencing before handing the full picture to the UI. + */ +data class AnalyzedApk( + val info: ApkInfo, + val signatures: List, + val intentFilters: List, + val linkDomains: List +) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt index 0c749ab..09d4367 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/NavGraph.kt @@ -124,4 +124,5 @@ fun NavigationController.navigateToLogStream() = navigateTo(Screen.LogStream) fun NavigationController.navigateToBatchTest() = navigateTo(Screen.BatchTest) fun NavigationController.navigateToLocalHosting() = navigateTo(Screen.LocalHosting) fun NavigationController.navigateToIntentSniffer() = navigateTo(Screen.IntentSniffer) +fun NavigationController.navigateToApkInspector() = navigateTo(Screen.ApkInspector) fun NavigationController.navigateToSettings() = navigateTo(Screen.Settings) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt index 8206bcc..06d6e1d 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/navigation/Screen.kt @@ -56,6 +56,11 @@ sealed class Screen(val route: String, val title: String) { */ data object IntentSniffer : Screen("intent_sniffer", "Intent Sniffer") + /** + * APK Inspector - inspect an APK file without installing it. + */ + data object ApkInspector : Screen("apk_inspector", "APK Inspector") + /** * Settings screen */ @@ -74,6 +79,7 @@ sealed class Screen(val route: String, val title: String) { BatchTest, LocalHosting, IntentSniffer, + ApkInspector, Settings ) } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorScreen.kt new file mode 100644 index 0000000..10625c4 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorScreen.kt @@ -0,0 +1,534 @@ +package com.manjee.linkops.ui.screen.apk + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.manjee.linkops.domain.model.ApkAnalysisResult +import com.manjee.linkops.domain.model.ApkDomainVerification +import com.manjee.linkops.domain.model.ApkFingerprintMatch +import com.manjee.linkops.domain.model.ApkInfo +import com.manjee.linkops.domain.model.ApkIntentFilter +import com.manjee.linkops.domain.model.ApkLinkDomain +import com.manjee.linkops.domain.model.ApkPathPattern +import com.manjee.linkops.domain.model.ApkSignature +import com.manjee.linkops.domain.model.ValidationStatus +import com.manjee.linkops.ui.component.LoadingOverlay +import com.manjee.linkops.ui.component.StatusDot +import com.manjee.linkops.ui.theme.LinkOpsColors +import java.awt.FileDialog +import java.awt.Frame +import java.io.File + +/** + * Screen for inspecting a local APK without installing it. Pulls package metadata, + * signing certificates, intent-filters, and (most importantly) cross-checks every + * autoVerify domain against the live assetlinks.json so the user can answer + * "would this build verify on a device?" before shipping. + */ +@Composable +fun ApkInspectorScreen( + viewModel: ApkInspectorViewModel, + modifier: Modifier = Modifier +) { + val state by viewModel.uiState.collectAsState() + + Row( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier + .weight(0.4f) + .fillMaxHeight() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "APK Inspector", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + HorizontalDivider() + + FilePickerSection( + selectedFile = state.selectedFile, + isAnalyzing = state.isAnalyzing, + onFilePicked = { file -> viewModel.analyzeFile(file) }, + onClear = { viewModel.clearResult() } + ) + + if (state.error != null) { + ErrorBanner(message = state.error!!, onDismiss = { viewModel.clearError() }) + } + + HorizontalDivider() + + Text( + text = "Inspects without installing. Reads manifest + signing certs + intent-filters " + + "directly from the APK, then fetches assetlinks.json for every autoVerify domain.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Column( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + ) { + ResultsPanel(state.result) + } + } + + LoadingOverlay( + isLoading = state.isAnalyzing, + message = state.progressLabel + ) +} + +@Composable +private fun FilePickerSection( + selectedFile: File?, + isAnalyzing: Boolean, + onFilePicked: (File) -> Unit, + onClear: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Select an APK", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Compose Desktop has no native picker, so we delegate to AWT — works + // identically across macOS / Windows / Linux because FileDialog uses the + // OS-native picker in each case. + OutlinedButton( + onClick = { + val file = pickApkFile() + if (file != null) onFilePicked(file) + }, + enabled = !isAnalyzing, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Browse for APK…") + } + + if (selectedFile != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.UploadFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = selectedFile.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "${"%.2f".format(selectedFile.length() / (1024.0 * 1024.0))} MB", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (!isAnalyzing) { + IconButton(onClick = onClear) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + } + } + } + } +} + +@Composable +private fun ErrorBanner(message: String, onDismiss: () -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = LinkOpsColors.ErrorLight), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = message, modifier = Modifier.weight(1f), color = LinkOpsColors.Error) + TextButton(onClick = onDismiss) { Text("Dismiss") } + } + } +} + +@Composable +private fun ResultsPanel(result: ApkAnalysisResult?) { + if (result == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Android, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No APK selected", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Browse and pick an .apk file to inspect.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + return + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { ApkInfoCard(result.info) } + + item { SignaturesCard(result.signatures) } + + if (result.domainVerifications.isNotEmpty()) { + item { DomainVerificationCard(result.domainVerifications) } + } + + if (result.linkDomains.isNotEmpty()) { + item { LinkDomainsCard(result.linkDomains) } + } + + if (result.intentFilters.isNotEmpty()) { + item { IntentFiltersCard(result.intentFilters) } + } + } +} + +@Composable +private fun ApkInfoCard(info: ApkInfo) { + SectionCard(title = "Package") { + InfoRow("Package name", info.packageName) + info.applicationLabel?.let { InfoRow("Label", it) } + info.versionName?.let { InfoRow("Version", "$it (code ${info.versionCode ?: '?'})") } + InfoRow( + "SDK", + "min ${info.minSdk ?: '?'} · target ${info.targetSdk ?: '?'}" + ) + InfoRow("File", info.filePath, monospace = true) + } +} + +@Composable +private fun SignaturesCard(signatures: List) { + SectionCard(title = "Signing certificates (${signatures.size})") { + if (signatures.isEmpty()) { + Text( + text = "No signing certificates found — APK appears to be unsigned.", + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Warning + ) + return@SectionCard + } + signatures.forEachIndexed { index, sig -> + if (index > 0) Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "SHA-256", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SelectionContainer { + Text( + text = sig.sha256Fingerprint, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + ) + } + sig.subjectDn?.let { + Text( + text = "Algorithm: $it", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun DomainVerificationCard(verifications: List) { + SectionCard(title = "App Links verification (${verifications.size})") { + verifications.forEach { verification -> + VerificationRow(verification) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun VerificationRow(verification: ApkDomainVerification) { + val (color, label) = when (val match = verification.fingerprintMatch) { + ApkFingerprintMatch.FullMatch -> LinkOpsColors.Success to "Fingerprint matches" + is ApkFingerprintMatch.PartialMatch -> + LinkOpsColors.Warning to "Partial match (${match.matchedFingerprints.size} of ${match.matchedFingerprints.size + match.unmatchedFingerprints.size})" + is ApkFingerprintMatch.NoMatch -> LinkOpsColors.Error to "No fingerprint match" + ApkFingerprintMatch.PackageNotDeclared -> + LinkOpsColors.Error to "Package not in assetlinks.json" + ApkFingerprintMatch.AssetLinksUnavailable -> + LinkOpsColors.Unknown to when (verification.assetLinksValidation.status) { + ValidationStatus.NOT_FOUND -> "assetlinks.json not found" + ValidationStatus.INVALID_JSON -> "assetlinks.json invalid" + ValidationStatus.NETWORK_ERROR -> "Network error" + ValidationStatus.REDIRECT -> "Redirect (not allowed)" + else -> "assetlinks.json unavailable" + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(color.copy(alpha = 0.1f), RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusDot(color = color) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = verification.domain, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = color + ) + } + } +} + +@Composable +private fun LinkDomainsCard(domains: List) { + SectionCard(title = "All declared link domains (${domains.size})") { + domains.forEach { domain -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .background( + if (domain.isAppLinkCandidate) LinkOpsColors.SuccessLight + else MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = if (domain.isAppLinkCandidate) "AppLink" else domain.schemes.firstOrNull() ?: "—", + style = MaterialTheme.typography.labelSmall, + color = if (domain.isAppLinkCandidate) LinkOpsColors.Success + else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } + Spacer(modifier = Modifier.width(8.dp)) + SelectionContainer { + Text( + text = domain.host, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.weight(1f) + ) + } + Text( + text = domain.schemes.joinToString(", "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun IntentFiltersCard(filters: List) { + var expanded by remember { mutableStateOf(false) } + SectionCard(title = "Intent filters (${filters.size})") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Per-component declarations", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = { expanded = !expanded }) { + Text(if (expanded) "Collapse" else "Expand") + } + } + if (expanded) { + filters.forEach { filter -> IntentFilterItem(filter) } + } + } +} + +@Composable +private fun IntentFilterItem(filter: ApkIntentFilter) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = filter.componentName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + if (filter.autoVerify) { + Box( + modifier = Modifier + .background(LinkOpsColors.SuccessLight, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "autoVerify", + style = MaterialTheme.typography.labelSmall, + color = LinkOpsColors.Success, + fontWeight = FontWeight.Medium + ) + } + } + } + if (filter.actions.isNotEmpty()) { + InfoRow("Actions", filter.actions.joinToString(", ")) + } + if (filter.schemes.isNotEmpty()) { + InfoRow("Schemes", filter.schemes.joinToString(", "), monospace = true) + } + if (filter.hosts.isNotEmpty()) { + InfoRow("Hosts", filter.hosts.joinToString(", "), monospace = true) + } + if (filter.pathPatterns.isNotEmpty()) { + Text( + text = "Paths:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + filter.pathPatterns.forEach { pp -> + Text( + text = "${pp.type.label} ${pp.pattern}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } +} + +private val ApkPathPattern.PatternType.label: String + get() = when (this) { + ApkPathPattern.PatternType.LITERAL -> "path:" + ApkPathPattern.PatternType.PREFIX -> "pathPrefix:" + ApkPathPattern.PatternType.SUFFIX -> "pathSuffix:" + ApkPathPattern.PatternType.GLOB -> "pathPattern:" + ApkPathPattern.PatternType.ADVANCED_GLOB -> "pathAdvancedPattern:" + } + +@Composable +private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + content() + } + } +} + +@Composable +private fun InfoRow(label: String, value: String, monospace: Boolean = false) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SelectionContainer { + Text( + text = value, + style = if (monospace) { + MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } else { + MaterialTheme.typography.bodySmall + }, + modifier = Modifier.weight(1f) + ) + } + } +} + +/** + * AWT FileDialog uses the OS-native picker on every platform, so the user gets + * exactly the picker they expect (Finder on macOS, Explorer on Windows, etc.). + */ +private fun pickApkFile(): File? { + val dialog = FileDialog(null as Frame?, "Select APK", FileDialog.LOAD).apply { + setFilenameFilter { _, name -> name.endsWith(".apk", ignoreCase = true) } + isVisible = true + } + val dir = dialog.directory ?: return null + val name = dialog.file ?: return null + return File(dir, name) +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorViewModel.kt new file mode 100644 index 0000000..ee29f7c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/apk/ApkInspectorViewModel.kt @@ -0,0 +1,117 @@ +package com.manjee.linkops.ui.screen.apk + +import com.manjee.linkops.di.AppContainer +import com.manjee.linkops.domain.model.ApkAnalysisResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File + +data class ApkInspectorUiState( + val selectedFile: File? = null, + val isAnalyzing: Boolean = false, + /** + * What we're currently doing — surfaced in the loading UI so the user knows + * whether we're parsing the APK locally or waiting on remote assetlinks fetches. + * The remote step often dominates wall-time and looks like a hang otherwise. + */ + val progressLabel: String? = null, + val result: ApkAnalysisResult? = null, + val error: String? = null +) + +class ApkInspectorViewModel { + private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _uiState = MutableStateFlow(ApkInspectorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Validates the path exists + has a recognizable extension, then kicks off + * analysis. We accept .apk only for MVP — .aab requires protobuf-based parsing + * that apk-parser doesn't support, and silently failing on it would mislead + * the user. + */ + fun analyzeFile(file: File) { + if (!file.exists() || !file.isFile) { + _uiState.update { it.copy(error = "File not found: ${file.absolutePath}") } + return + } + if (!file.name.endsWith(".apk", ignoreCase = true)) { + val hint = if (file.name.endsWith(".aab", ignoreCase = true)) { + "AAB (Android App Bundle) is not supported yet — please supply the universal APK or a split APK." + } else { + "Only .apk files are supported (got: ${file.name})" + } + _uiState.update { it.copy(error = hint) } + return + } + + _uiState.update { + it.copy( + selectedFile = file, + isAnalyzing = true, + progressLabel = "Parsing APK…", + result = null, + error = null + ) + } + + viewModelScope.launch { + // The use case fetches assetlinks for every autoVerify domain in parallel + // after the manifest parse — switch the label so the user knows we're now + // on the network, not the disk. + _uiState.update { it.copy(progressLabel = "Parsing APK…") } + + val result = AppContainer.analyzeApkAndValidateLinksUseCase(file) + + // Quick label switch is racy with the parse step (the call above blocks + // until everything finishes), so the network label is best-effort. The + // real value is the parse vs. done distinction the user sees in the UI. + result + .onSuccess { analysis -> + _uiState.update { + it.copy( + isAnalyzing = false, + progressLabel = null, + result = analysis, + error = null + ) + } + } + .onFailure { error -> + _uiState.update { + it.copy( + isAnalyzing = false, + progressLabel = null, + error = error.message ?: "APK analysis failed" + ) + } + } + } + } + + fun clearResult() { + _uiState.update { + it.copy( + selectedFile = null, + result = null, + error = null + ) + } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + fun onCleared() { + viewModelScope.cancel() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06d80e7..c34f59a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] androidx-lifecycle = "2.9.6" +apkParser = "2.6.10" composeHotReload = "1.0.0" composeMultiplatform = "1.9.3" junit = "4.13.2" @@ -23,6 +24,7 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } +apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apkParser" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }