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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ fun App() {
}

Screen.Diagnostics -> {
DiagnosticsScreen(viewModel = diagnosticsViewModel)
val mainUiState by mainViewModel.uiState.collectAsState()
DiagnosticsScreen(
viewModel = diagnosticsViewModel,
devices = mainUiState.devices
)
}

Screen.ManifestAnalyzer -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package com.manjee.linkops.data.parser

import com.manjee.linkops.domain.model.SchemeRegistration

/**
* Parser for extracting URI scheme registrations from `adb shell dumpsys package` output
*
* Extracts intent filters with VIEW action and BROWSABLE category to find
* all URI scheme registrations for collision detection.
*
* Example output:
* ```
* Activity Resolver Table:
* Non-Data Actions:
* ...
* Schemes:
* http:
* 3853b45 com.example.app/.MainActivity filter c6ebba8
* Action: "android.intent.action.VIEW"
* Category: "android.intent.category.DEFAULT"
* Category: "android.intent.category.BROWSABLE"
* AutoVerify=true
* Scheme: "http"
* Scheme: "https"
* Authority: "example.com": -1
* Path: "PatternMatcher{LITERAL: /path}"
* ```
*/
class IntentFilterParser {

/**
* Parse dumpsys package output and extract scheme registrations
*
* @param packageName Package name being parsed
* @param dumpsysOutput Raw output from `dumpsys package <packageName>`
* @return List of scheme registrations found in the package
*/
fun parse(packageName: String, dumpsysOutput: String): List<SchemeRegistration> {
if (dumpsysOutput.isBlank()) return emptyList()
if (dumpsysOutput.startsWith("error:") || dumpsysOutput.contains("Unable to find package")) {
return emptyList()
}

val activitySection = extractActivitySection(dumpsysOutput) ?: return emptyList()
val activityBlocks = parseActivityBlocks(activitySection)

return activityBlocks.flatMap { (activityName, filterBlocks) ->
filterBlocks.mapNotNull { filterBlock ->
parseFilterBlock(packageName, activityName, filterBlock)
}.flatten()
}
}

private fun extractActivitySection(output: String): String? {
val startIndex = output.indexOf("Activity Resolver Table:")
if (startIndex == -1) return null

val endMarkers = listOf("Receiver Resolver Table:", "Service Resolver Table:", "Provider Resolver Table:")
var endIndex = output.length

for (marker in endMarkers) {
val idx = output.indexOf(marker, startIndex)
if (idx != -1 && idx < endIndex) {
endIndex = idx
}
}

return output.substring(startIndex, endIndex)
}

private fun parseActivityBlocks(section: String): Map<String, List<String>> {
val result = mutableMapOf<String, MutableList<String>>()
val lines = section.lines()

var currentActivity: String? = null
var currentFilter = StringBuilder()
var inFilter = false

for (line in lines) {
val activityMatch = ACTIVITY_PATTERN.find(line)
if (activityMatch != null) {
if (currentActivity != null && currentFilter.isNotEmpty()) {
result.getOrPut(currentActivity) { mutableListOf() }.add(currentFilter.toString())
}
currentActivity = activityMatch.groupValues[1]
currentFilter = StringBuilder()
inFilter = true
continue
}

if (inFilter && currentActivity != null) {
if (line.isNotBlank() && !line.startsWith(" ") && !line.startsWith("\t")) {
if (ACTIVITY_PATTERN.containsMatchIn(line)) {
// Will be handled in next iteration
} else if (!line.startsWith(" ")) {
inFilter = false
}
}
if (inFilter) {
currentFilter.appendLine(line)
}
}
}

if (currentActivity != null && currentFilter.isNotEmpty()) {
result.getOrPut(currentActivity) { mutableListOf() }.add(currentFilter.toString())
}

return result
}

private fun parseFilterBlock(
packageName: String,
activityName: String,
filterBlock: String
): List<SchemeRegistration>? {
val actions = mutableListOf<String>()
val categories = mutableListOf<String>()
val schemes = mutableListOf<String>()
val paths = mutableListOf<Triple<String?, String?, String?>>()
var host: String? = null
var autoVerify = false

for (line in filterBlock.lines()) {
val trimmed = line.trim()

if (trimmed.startsWith("Action:")) {
val action = trimmed.removePrefix("Action:").trim().removeSurrounding("\"")
if (action.isNotEmpty()) actions.add(action)
}

if (trimmed.startsWith("Category:")) {
val category = trimmed.removePrefix("Category:").trim().removeSurrounding("\"")
if (category.isNotEmpty()) categories.add(category)
}

if (trimmed.contains("AutoVerify=true", ignoreCase = true)) {
autoVerify = true
}

if (trimmed.startsWith("Scheme:")) {
val scheme = trimmed.removePrefix("Scheme:").trim().removeSurrounding("\"")
if (scheme.isNotEmpty()) schemes.add(scheme)
}

if (trimmed.startsWith("Authority:")) {
val authorityMatch = AUTHORITY_PATTERN.find(trimmed)
if (authorityMatch != null) {
host = authorityMatch.groupValues[1]
}
}

if (trimmed.startsWith("Path:")) {
val pathMatch = PATH_PATTERN.find(trimmed)
if (pathMatch != null) {
val pathType = pathMatch.groupValues[1]
val pathValue = pathMatch.groupValues[2].trim()
when (pathType.uppercase()) {
"LITERAL" -> paths.add(Triple(pathValue, null, null))
"PREFIX" -> paths.add(Triple(null, pathValue, null))
"GLOB", "ADVANCED_GLOB" -> paths.add(Triple(null, null, pathValue))
}
}
}

if (trimmed.startsWith("PathPrefix:")) {
val pathPrefix = trimmed.removePrefix("PathPrefix:").trim().removeSurrounding("\"")
if (pathPrefix.isNotEmpty()) paths.add(Triple(null, pathPrefix, null))
}

if (trimmed.startsWith("PathPattern:")) {
val pathPattern = trimmed.removePrefix("PathPattern:").trim().removeSurrounding("\"")
if (pathPattern.isNotEmpty()) paths.add(Triple(null, null, pathPattern))
}
}

// Only consider VIEW + BROWSABLE filters
val isViewAction = actions.contains("android.intent.action.VIEW")
val isBrowsable = categories.contains("android.intent.category.BROWSABLE")
if (!isViewAction || !isBrowsable || schemes.isEmpty()) return null

val registrations = mutableListOf<SchemeRegistration>()

if (paths.isEmpty()) {
for (scheme in schemes) {
registrations.add(
SchemeRegistration(
packageName = packageName,
activityName = activityName,
scheme = scheme,
host = host,
path = null,
pathPrefix = null,
pathPattern = null,
autoVerify = autoVerify
)
)
}
} else {
for (scheme in schemes) {
for ((path, pathPrefix, pathPattern) in paths) {
registrations.add(
SchemeRegistration(
packageName = packageName,
activityName = activityName,
scheme = scheme,
host = host,
path = path,
pathPrefix = pathPrefix,
pathPattern = pathPattern,
autoVerify = autoVerify
)
)
}
}
}

return registrations
}

companion object {
private val ACTIVITY_PATTERN = Regex("""^\s+\w+\s+([a-zA-Z0-9_.]+/[a-zA-Z0-9_.]+)\s+filter""")
private val AUTHORITY_PATTERN = Regex(""""([^"]+)":\s*(-?\d+)""")
private val PATH_PATTERN = Regex("""PatternMatcher\{(\w+):\s*([^}]+)\}""")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.manjee.linkops.data.repository

import com.manjee.linkops.data.parser.IntentFilterParser
import com.manjee.linkops.domain.model.CollisionGroup
import com.manjee.linkops.domain.model.CollisionReport
import com.manjee.linkops.domain.model.CollisionSeverity
import com.manjee.linkops.domain.model.SchemeRegistration
import com.manjee.linkops.domain.repository.CollisionRepository
import com.manjee.linkops.infrastructure.adb.AdbShellExecutor

/**
* Implementation of CollisionRepository using ADB commands
*
* Scans third-party installed apps, parses their intent filters,
* and detects URI scheme collisions.
*/
class CollisionRepositoryImpl(
private val adbExecutor: AdbShellExecutor,
private val intentFilterParser: IntentFilterParser
) : CollisionRepository {

override suspend fun detectCollisions(deviceSerial: String): Result<CollisionReport> {
return runCatching {
// Get third-party packages
val packagesOutput = adbExecutor.executeOnDevice(
deviceSerial,
"pm list packages -3"
).getOrElse { error ->
return Result.failure(error)
}

val packages = packagesOutput.lines()
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:").trim() }
.filter { it.isNotBlank() }

// Collect all scheme registrations
val allRegistrations = mutableListOf<SchemeRegistration>()

for (packageName in packages) {
val dumpsysOutput = adbExecutor.executeOnDevice(
deviceSerial,
"dumpsys package $packageName"
).getOrNull() ?: continue

val registrations = intentFilterParser.parse(packageName, dumpsysOutput)
allRegistrations.addAll(registrations)
}

// Group by collision key (scheme://host) and find collisions
val collisionGroups = buildCollisionGroups(allRegistrations)

CollisionReport(
collisionGroups = collisionGroups,
totalRegistrations = allRegistrations.size,
scannedPackages = packages.size
)
}
}

private fun buildCollisionGroups(registrations: List<SchemeRegistration>): List<CollisionGroup> {
// Group by collision key (scheme://host)
val grouped = registrations.groupBy { it.collisionKey }

return grouped.mapNotNull { (pattern, regs) ->
// Only consider groups with multiple distinct packages
val distinctPackages = regs.map { it.packageName }.distinct()
if (distinctPackages.size < 2) return@mapNotNull null

// Determine severity: exact scheme+host+path match = CRITICAL, otherwise WARNING
val severity = determineSeverity(regs)

CollisionGroup(
pattern = pattern,
severity = severity,
registrations = regs
)
}.sortedWith(
compareBy<CollisionGroup> { it.severity.ordinal }
.thenByDescending { it.appCount }
)
}

private fun determineSeverity(registrations: List<SchemeRegistration>): CollisionSeverity {
// Group by full URI pattern to check for exact matches
val byFullPattern = registrations.groupBy { it.uriPattern }
val hasExactMatch = byFullPattern.any { (_, regs) ->
regs.map { it.packageName }.distinct().size > 1
}

return if (hasExactMatch) CollisionSeverity.CRITICAL else CollisionSeverity.WARNING
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import com.manjee.linkops.data.parser.AmStartOutputParser
import com.manjee.linkops.data.parser.AssetLinksParser
import com.manjee.linkops.data.parser.DumpsysParser
import com.manjee.linkops.data.parser.GetAppLinksParser
import com.manjee.linkops.data.parser.IntentFilterParser
import com.manjee.linkops.data.parser.LogcatParser
import com.manjee.linkops.data.parser.ManifestParser
import com.manjee.linkops.data.repository.AppLinkRepositoryImpl
import com.manjee.linkops.data.repository.AssetLinksRepositoryImpl
import com.manjee.linkops.data.repository.BatchTestRepositoryImpl
import com.manjee.linkops.data.repository.CollisionRepositoryImpl
import com.manjee.linkops.data.repository.DeviceRepositoryImpl
import com.manjee.linkops.data.repository.FavoriteRepositoryImpl
import com.manjee.linkops.data.repository.LogStreamRepositoryImpl
Expand All @@ -23,6 +25,7 @@ import com.manjee.linkops.data.strategy.AdbCommandStrategyFactory
import com.manjee.linkops.domain.repository.AppLinkRepository
import com.manjee.linkops.domain.repository.AssetLinksRepository
import com.manjee.linkops.domain.repository.BatchTestRepository
import com.manjee.linkops.domain.repository.CollisionRepository
import com.manjee.linkops.domain.repository.DeviceRepository
import com.manjee.linkops.domain.repository.FavoriteRepository
import com.manjee.linkops.domain.repository.LogStreamRepository
Expand All @@ -37,6 +40,7 @@ import com.manjee.linkops.domain.usecase.batchtest.ImportScenarioUseCase
import com.manjee.linkops.domain.usecase.batchtest.ResolveTemplateUrisUseCase
import com.manjee.linkops.domain.usecase.device.DetectDevicesUseCase
import com.manjee.linkops.domain.usecase.diagnostics.AnalyzeVerificationUseCase
import com.manjee.linkops.domain.usecase.diagnostics.DetectCollisionsUseCase
import com.manjee.linkops.domain.usecase.diagnostics.ValidateAssetLinksUseCase
import com.manjee.linkops.domain.usecase.favorite.AddFavoriteUseCase
import com.manjee.linkops.domain.usecase.favorite.ObserveFavoritesUseCase
Expand Down Expand Up @@ -113,6 +117,10 @@ object AppContainer {
ParameterSubstituter()
}

private val intentFilterParser: IntentFilterParser by lazy {
IntentFilterParser()
}

// Data - Strategy
private val strategyFactory: AdbCommandStrategyFactory by lazy {
AdbCommandStrategyFactory()
Expand Down Expand Up @@ -173,6 +181,10 @@ object AppContainer {
BatchTestRepositoryImpl(adbShellExecutor, amStartOutputParser, scenarioMapper)
}

val collisionRepository: CollisionRepository by lazy {
CollisionRepositoryImpl(adbShellExecutor, intentFilterParser)
}

// UseCases - Device
val detectDevicesUseCase: DetectDevicesUseCase by lazy {
DetectDevicesUseCase(deviceRepository)
Expand Down Expand Up @@ -200,6 +212,10 @@ object AppContainer {
AnalyzeVerificationUseCase(verificationDiagnosticsRepository)
}

val detectCollisionsUseCase: DetectCollisionsUseCase by lazy {
DetectCollisionsUseCase(collisionRepository)
}

// UseCases - Manifest
val analyzeManifestUseCase: AnalyzeManifestUseCase by lazy {
AnalyzeManifestUseCase(manifestRepository)
Expand Down
Loading