Skip to content
Open
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
1 change: 1 addition & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand All @@ -97,6 +101,7 @@ fun App() {
batchTestViewModel.onCleared()
localHostingViewModel.onCleared()
intentSnifferViewModel.onCleared()
apkInspectorViewModel.onCleared()
settingsViewModel.onCleared()
}
}
Expand Down Expand Up @@ -219,6 +224,10 @@ fun App() {
)
}

Screen.ApkInspector -> {
ApkInspectorScreen(viewModel = apkInspectorViewModel)
}

Screen.Settings -> {
SettingsScreen(viewModel = settingsViewModel)
}
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApkAnalysisResult> {
return analyzer.analyze(apkFile).map { analyzed ->
ApkAnalysisResult(
info = analyzed.info,
signatures = analyzed.signatures,
intentFilters = analyzed.intentFilters,
linkDomains = analyzed.linkDomains,
domainVerifications = emptyList()
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -140,6 +145,11 @@ object AppContainer {
AasaParser()
}

// Infrastructure - APK
private val apkAnalyzer: ApkAnalyzer by lazy {
ApkAnalyzer()
}

private val manifestParser: ManifestParser by lazy {
ManifestParser()
}
Expand Down Expand Up @@ -213,6 +223,10 @@ object AppContainer {
AasaRepositoryImpl(aasaClient, aasaParser)
}

val apkAnalysisRepository: ApkAnalysisRepository by lazy {
ApkAnalysisRepositoryImpl(apkAnalyzer)
}

val manifestRepository: ManifestRepository by lazy {
ManifestRepositoryImpl(adbShellExecutor, manifestParser)
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApkSignature>,
val intentFilters: List<ApkIntentFilter>,
val linkDomains: List<ApkLinkDomain>,
val domainVerifications: List<ApkDomainVerification> = 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 <intent-filter> 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<String>,
val categories: List<String>,
val schemes: List<String>,
val hosts: List<String>,
val pathPatterns: List<ApkPathPattern>,
val autoVerify: Boolean,
val mimeTypes: List<String> = 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<String>,
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<String>,
val unmatchedFingerprints: List<String>
) : ApkFingerprintMatch()

/** assetlinks.json contains the package but none of this APK's signatures match. */
data class NoMatch(
val apkFingerprints: List<String>,
val assetLinksFingerprints: List<String>
) : 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()
}
Original file line number Diff line number Diff line change
@@ -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<ApkAnalysisResult>
}
Original file line number Diff line number Diff line change
@@ -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<ApkAnalysisResult> = runCatching {
val analysis = apkAnalysisRepository.analyzeApk(apkFile).getOrThrow()
val verifications = verifyAutoVerifyDomains(analysis.linkDomains, analysis.signatures)
analysis.copy(domainVerifications = verifications)
}

private suspend fun verifyAutoVerifyDomains(
domains: List<ApkLinkDomain>,
signatures: List<ApkSignature>
): List<ApkDomainVerification> = 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<String>
): 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<String>,
@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()
}
Loading
Loading