diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt index 95e3950..8757439 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt @@ -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 -> { diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/IntentFilterParser.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/IntentFilterParser.kt new file mode 100644 index 0000000..92596ec --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/IntentFilterParser.kt @@ -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 ` + * @return List of scheme registrations found in the package + */ + fun parse(packageName: String, dumpsysOutput: String): List { + 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> { + val result = mutableMapOf>() + 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? { + val actions = mutableListOf() + val categories = mutableListOf() + val schemes = mutableListOf() + val paths = mutableListOf>() + 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() + + 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*([^}]+)\}""") + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/CollisionRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/CollisionRepositoryImpl.kt new file mode 100644 index 0000000..a9406aa --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/CollisionRepositoryImpl.kt @@ -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 { + 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() + + 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): List { + // 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 { it.severity.ordinal } + .thenByDescending { it.appCount } + ) + } + + private fun determineSeverity(registrations: List): 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 + } +} 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 be790bf..540dd09 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -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 @@ -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 @@ -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 @@ -113,6 +117,10 @@ object AppContainer { ParameterSubstituter() } + private val intentFilterParser: IntentFilterParser by lazy { + IntentFilterParser() + } + // Data - Strategy private val strategyFactory: AdbCommandStrategyFactory by lazy { AdbCommandStrategyFactory() @@ -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) @@ -200,6 +212,10 @@ object AppContainer { AnalyzeVerificationUseCase(verificationDiagnosticsRepository) } + val detectCollisionsUseCase: DetectCollisionsUseCase by lazy { + DetectCollisionsUseCase(collisionRepository) + } + // UseCases - Manifest val analyzeManifestUseCase: AnalyzeManifestUseCase by lazy { AnalyzeManifestUseCase(manifestRepository) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/SchemeCollision.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/SchemeCollision.kt new file mode 100644 index 0000000..e8ea0f0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/SchemeCollision.kt @@ -0,0 +1,107 @@ +package com.manjee.linkops.domain.model + +/** + * Represents a single URI scheme registration from an app's intent filter + */ +data class SchemeRegistration( + val packageName: String, + val activityName: String, + val scheme: String, + val host: String?, + val path: String?, + val pathPrefix: String?, + val pathPattern: String?, + val autoVerify: Boolean = false +) { + /** + * URI pattern combining scheme, host, and path info + */ + val uriPattern: String + get() { + val sb = StringBuilder(scheme).append("://") + host?.let { sb.append(it) } ?: sb.append("*") + + when { + path != null -> sb.append(path) + pathPrefix != null -> sb.append(pathPrefix).append("*") + pathPattern != null -> sb.append(pathPattern) + else -> sb.append("/*") + } + + return sb.toString() + } + + /** + * Key for grouping collisions — scheme + host combination + */ + val collisionKey: String + get() = "$scheme://${host ?: "*"}" +} + +/** + * Severity level for a collision group + */ +enum class CollisionSeverity { + CRITICAL, // Exact scheme+host match across apps + WARNING; // Partial overlap (same scheme, different host/path patterns) + + val displayName: String + get() = when (this) { + CRITICAL -> "Critical" + WARNING -> "Warning" + } +} + +/** + * A group of apps that share the same URI scheme+host pattern + */ +data class CollisionGroup( + val pattern: String, + val severity: CollisionSeverity, + val registrations: List +) { + /** + * Number of distinct apps in this collision group + */ + val appCount: Int + get() = registrations.map { it.packageName }.distinct().size + + /** + * List of distinct package names involved in the collision + */ + val packageNames: List + get() = registrations.map { it.packageName }.distinct() + + /** + * Whether any registration has autoVerify enabled + */ + val hasVerifiedApp: Boolean + get() = registrations.any { it.autoVerify } +} + +/** + * Complete collision detection result + */ +data class CollisionReport( + val collisionGroups: List, + val totalRegistrations: Int, + val scannedPackages: Int +) { + /** + * Number of critical collisions + */ + val criticalCount: Int + get() = collisionGroups.count { it.severity == CollisionSeverity.CRITICAL } + + /** + * Number of warning collisions + */ + val warningCount: Int + get() = collisionGroups.count { it.severity == CollisionSeverity.WARNING } + + /** + * Whether any collisions were detected + */ + val hasCollisions: Boolean + get() = collisionGroups.isNotEmpty() +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/CollisionRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/CollisionRepository.kt new file mode 100644 index 0000000..1974ce3 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/CollisionRepository.kt @@ -0,0 +1,16 @@ +package com.manjee.linkops.domain.repository + +import com.manjee.linkops.domain.model.CollisionReport + +/** + * Repository for detecting URI scheme collisions across installed apps + */ +interface CollisionRepository { + /** + * Scan installed apps on a device and detect URI scheme collisions + * + * @param deviceSerial Serial number of the target device + * @return CollisionReport with detected collision groups + */ + suspend fun detectCollisions(deviceSerial: String): Result +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/diagnostics/DetectCollisionsUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/diagnostics/DetectCollisionsUseCase.kt new file mode 100644 index 0000000..5d6d4ba --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/diagnostics/DetectCollisionsUseCase.kt @@ -0,0 +1,21 @@ +package com.manjee.linkops.domain.usecase.diagnostics + +import com.manjee.linkops.domain.model.CollisionReport +import com.manjee.linkops.domain.repository.CollisionRepository + +/** + * Detects URI scheme collisions across installed apps on a device + */ +class DetectCollisionsUseCase( + private val collisionRepository: CollisionRepository +) { + /** + * Scan installed apps and detect URI scheme collisions + * + * @param deviceSerial Serial number of the target device + * @return CollisionReport with detected collision groups + */ + suspend operator fun invoke(deviceSerial: String): Result { + return collisionRepository.detectCollisions(deviceSerial) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt index e2cf6ac..a21e8ec 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -20,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.manjee.linkops.LocalSearchFocusTrigger @@ -28,19 +30,74 @@ import com.manjee.linkops.ui.component.* import com.manjee.linkops.ui.theme.LinkOpsColors /** - * Diagnostics Screen for validating assetlinks.json + * Diagnostics Screen with tabbed layout for AssetLinks validation and Collision Detection */ @Composable fun DiagnosticsScreen( viewModel: DiagnosticsViewModel, + devices: List = emptyList(), modifier: Modifier = Modifier ) { val uiState by viewModel.uiState.collectAsState() - Row( + Column( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) + ) { + // Tab row + TabRow( + selectedTabIndex = uiState.activeTab.ordinal, + containerColor = MaterialTheme.colorScheme.surface + ) { + DiagnosticsTab.entries.forEach { tab -> + Tab( + selected = uiState.activeTab == tab, + onClick = { viewModel.switchTab(tab) }, + text = { Text(tab.title) } + ) + } + } + + // Tab content + when (uiState.activeTab) { + DiagnosticsTab.ASSET_LINKS -> AssetLinksContent( + uiState = uiState, + viewModel = viewModel + ) + DiagnosticsTab.COLLISION_DETECTOR -> CollisionDetectorContent( + uiState = uiState, + devices = devices, + viewModel = viewModel + ) + } + } + + // Loading overlay + val isLoading = uiState.isLoading || uiState.isDetectingCollisions + val loadingMessage = when { + uiState.isLoading -> "Validating assetlinks.json..." + uiState.isDetectingCollisions -> "Scanning apps for collisions..." + else -> null + } + LoadingOverlay( + isLoading = isLoading, + message = loadingMessage + ) +} + +// -- AssetLinks Tab -- + +/** + * AssetLinks validation tab content + */ +@Composable +private fun AssetLinksContent( + uiState: DiagnosticsUiState, + viewModel: DiagnosticsViewModel +) { + Row( + modifier = Modifier.fillMaxSize() ) { // Left Panel - Input and History Column( @@ -50,7 +107,6 @@ fun DiagnosticsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Header Text( text = "AssetLinks Diagnostics", style = MaterialTheme.typography.headlineMedium, @@ -59,7 +115,6 @@ fun DiagnosticsScreen( HorizontalDivider() - // Domain input DomainInputSection( domain = uiState.domain, onDomainChange = { viewModel.updateDomain(it) }, @@ -70,7 +125,6 @@ fun DiagnosticsScreen( HorizontalDivider() - // History HistorySection( history = uiState.history, onSelectDomain = { viewModel.validateFromHistory(it) }, @@ -93,14 +147,475 @@ fun DiagnosticsScreen( ) } } +} - // Loading overlay - LoadingOverlay( - isLoading = uiState.isLoading, - message = "Validating assetlinks.json..." - ) +// -- Collision Detector Tab -- + +/** + * Collision detection tab content + */ +@Composable +private fun CollisionDetectorContent( + uiState: DiagnosticsUiState, + devices: List, + viewModel: DiagnosticsViewModel +) { + Row( + modifier = Modifier.fillMaxSize() + ) { + // Left Panel - Device selection and scan controls + Column( + modifier = Modifier + .weight(0.4f) + .fillMaxHeight() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Scheme Collision Detector", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + HorizontalDivider() + + DeviceSelectionSection( + devices = devices, + selectedDeviceSerial = uiState.selectedDeviceSerial, + onSelectDevice = { viewModel.updateSelectedDevice(it) } + ) + + HorizontalDivider() + + Button( + onClick = { viewModel.detectCollisions() }, + enabled = !uiState.isDetectingCollisions && uiState.selectedDeviceSerial != null, + modifier = Modifier.fillMaxWidth() + ) { + Text("Scan for Collisions") + } + + if (uiState.collisionError != null) { + Text( + text = uiState.collisionError, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Text( + text = "Scans all third-party apps for conflicting URI scheme registrations", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Summary card + if (uiState.collisionReport != null) { + CollisionSummaryCard(report = uiState.collisionReport) + } + } + + // Right Panel - Collision results + Column( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + ) { + CollisionResultsPanel( + report = uiState.collisionReport, + isLoading = uiState.isDetectingCollisions, + onClear = { viewModel.clearCollisionResult() } + ) + } + } +} + +/** + * Device selection section for collision detection + */ +@Composable +private fun DeviceSelectionSection( + devices: List, + selectedDeviceSerial: String?, + onSelectDevice: (Device?) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Target Device", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + if (devices.isEmpty()) { + Text( + text = "No devices connected. Connect a device via Dashboard first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val onlineDevices = devices.filter { it.isAvailable } + if (onlineDevices.isEmpty()) { + Text( + text = "No online devices available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + onlineDevices.forEach { device -> + val isSelected = device.serialNumber == selectedDeviceSerial + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelectDevice(device) }, + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusDot(color = LinkOpsColors.DeviceOnline) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = device.serialNumber, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (isSelected) { + Text( + text = "Selected", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } +} + +/** + * Summary card showing collision statistics + */ +@Composable +private fun CollisionSummaryCard(report: CollisionReport) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Scan Summary", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Packages scanned", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${report.scannedPackages}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "URI registrations", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${report.totalRegistrations}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Critical collisions", + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Error + ) + Text( + text = "${report.criticalCount}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = if (report.criticalCount > 0) LinkOpsColors.Error + else MaterialTheme.colorScheme.onSurface + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Warnings", + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Warning + ) + Text( + text = "${report.warningCount}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = if (report.warningCount > 0) LinkOpsColors.Warning + else MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +/** + * Collision results panel + */ +@Composable +private fun CollisionResultsPanel( + report: CollisionReport?, + isLoading: Boolean, + onClear: () -> Unit +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Collision Results", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + if (report != null) { + IconButton(onClick = onClear) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + } + + if (report == null && !isLoading) { + EmptyState( + title = "No collision results", + description = "Select a device and click Scan to detect collisions", + icon = Icons.Default.Warning + ) + } else if (report != null) { + if (!report.hasCollisions) { + EmptyState( + title = "No collisions detected", + description = "Scanned ${report.scannedPackages} packages with ${report.totalRegistrations} URI registrations", + icon = Icons.Default.Search + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(report.collisionGroups) { group -> + CollisionGroupCard(group) + } + } + } + } + } +} + +/** + * Card displaying a single collision group + */ +@Composable +private fun CollisionGroupCard(group: CollisionGroup) { + val severityColor = when (group.severity) { + CollisionSeverity.CRITICAL -> LinkOpsColors.Error + CollisionSeverity.WARNING -> LinkOpsColors.Warning + } + + val severityBgColor = when (group.severity) { + CollisionSeverity.CRITICAL -> LinkOpsColors.ErrorLight + CollisionSeverity.WARNING -> LinkOpsColors.WarningLight + } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Header: severity badge + pattern + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .background(severityBgColor, RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = group.severity.displayName, + style = MaterialTheme.typography.labelSmall, + color = severityColor, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = group.pattern, + style = MaterialTheme.typography.titleSmall.copy( + fontFamily = FontFamily.Monospace + ), + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = "${group.appCount} apps", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (group.hasVerifiedApp) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + StatusDot(color = LinkOpsColors.Verified, size = 6) + Text( + text = "Contains verified app(s)", + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Verified + ) + } + } + + HorizontalDivider() + + // List conflicting registrations grouped by package + val byPackage = group.registrations.groupBy { it.packageName } + byPackage.forEach { (packageName, registrations) -> + CollisionRegistrationItem(packageName, registrations) + } + } + } } +/** + * Display registrations for a single package within a collision group + */ +@Composable +private fun CollisionRegistrationItem( + packageName: String, + registrations: List +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(8.dp) + ) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = packageName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (registrations.any { it.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.Verified, + fontSize = 10.sp + ) + } + } + } + + registrations.forEach { reg -> + Text( + text = reg.uriPattern, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) + + Text( + text = reg.activityName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + fontSize = 10.sp, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +// -- Existing AssetLinks composables (unchanged) -- + /** * Domain input section */ diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt index dab087b..35378f9 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt @@ -2,19 +2,34 @@ package com.manjee.linkops.ui.screen.diagnostics import com.manjee.linkops.di.AppContainer import com.manjee.linkops.domain.model.AssetLinksValidation +import com.manjee.linkops.domain.model.CollisionReport +import com.manjee.linkops.domain.model.Device import com.manjee.linkops.domain.model.ValidationStatus import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +/** + * Active tab on the Diagnostics screen + */ +enum class DiagnosticsTab(val title: String) { + ASSET_LINKS("AssetLinks"), + COLLISION_DETECTOR("Collision Detector") +} + /** * UI State for Diagnostics Screen */ data class DiagnosticsUiState( + val activeTab: DiagnosticsTab = DiagnosticsTab.ASSET_LINKS, val domain: String = "", val isLoading: Boolean = false, val validation: AssetLinksValidation? = null, val error: String? = null, - val history: List = emptyList() + val history: List = emptyList(), + val collisionReport: CollisionReport? = null, + val isDetectingCollisions: Boolean = false, + val collisionError: String? = null, + val selectedDeviceSerial: String? = null ) /** @@ -29,7 +44,7 @@ data class ValidationHistoryItem( /** * ViewModel for Diagnostics Screen * - * Handles assetlinks.json validation and result display + * Handles assetlinks.json validation and URI scheme collision detection */ class DiagnosticsViewModel { private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -37,6 +52,13 @@ class DiagnosticsViewModel { private val _uiState = MutableStateFlow(DiagnosticsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** + * Switch active tab + */ + fun switchTab(tab: DiagnosticsTab) { + _uiState.update { it.copy(activeTab = tab) } + } + /** * Update domain input */ @@ -107,6 +129,53 @@ class DiagnosticsViewModel { _uiState.update { it.copy(history = emptyList()) } } + /** + * Update selected device for collision detection + */ + fun updateSelectedDevice(device: Device?) { + _uiState.update { it.copy(selectedDeviceSerial = device?.serialNumber) } + } + + /** + * Detect URI scheme collisions on the selected device + */ + fun detectCollisions() { + val deviceSerial = _uiState.value.selectedDeviceSerial + if (deviceSerial == null) { + _uiState.update { it.copy(collisionError = "No device selected") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isDetectingCollisions = true, collisionError = null) } + + AppContainer.detectCollisionsUseCase(deviceSerial) + .onSuccess { report -> + _uiState.update { + it.copy( + isDetectingCollisions = false, + collisionReport = report + ) + } + } + .onFailure { error -> + _uiState.update { + it.copy( + isDetectingCollisions = false, + collisionError = error.message ?: "Collision detection failed" + ) + } + } + } + } + + /** + * Clear collision detection result + */ + fun clearCollisionResult() { + _uiState.update { it.copy(collisionReport = null, collisionError = null) } + } + /** * Cleanup resources */ diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/IntentFilterParserTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/IntentFilterParserTest.kt new file mode 100644 index 0000000..030ea13 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/IntentFilterParserTest.kt @@ -0,0 +1,430 @@ +package com.manjee.linkops.data.parser + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IntentFilterParserTest { + + private val parser = IntentFilterParser() + + @Test + fun `parse should return empty list for empty string`() { + val result = parser.parse("com.example", "") + assertTrue(result.isEmpty(), "Empty input should return empty list") + } + + @Test + fun `parse should return empty list for blank string`() { + val result = parser.parse("com.example", " \n \n ") + assertTrue(result.isEmpty(), "Blank input should return empty list") + } + + @Test + fun `parse should return empty list for adb error message`() { + val result = parser.parse("com.example", "error: device not found") + assertTrue(result.isEmpty(), "ADB error should return empty list") + } + + @Test + fun `parse should return empty list for error closed message`() { + val result = parser.parse("com.example", "error: closed") + assertTrue(result.isEmpty(), "ADB closed error should return empty list") + } + + @Test + fun `parse should return empty list for package not found`() { + val result = parser.parse("com.example", "Unable to find package: com.example") + assertTrue(result.isEmpty(), "Package not found should return empty list") + } + + @Test + fun `parse should return empty list for no Activity Resolver Table`() { + val output = """ + Package [com.example] (abcdef1): + versionCode=1 + versionName=1.0.0 + Some other content + """.trimIndent() + + val result = parser.parse("com.example", output) + assertTrue(result.isEmpty(), "No Activity Resolver Table should return empty list") + } + + @Test + fun `parse should extract single scheme registration`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size, "Should find one registration") + assertEquals("com.example.app", result[0].packageName) + assertEquals("com.example.app/.MainActivity", result[0].activityName) + assertEquals("https", result[0].scheme) + assertEquals("example.com", result[0].host) + } + + @Test + fun `parse should extract multiple schemes from same filter`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "http" + Scheme: "https" + Authority: "example.com": -1 + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(2, result.size, "Should find two registrations (http + https)") + + val schemes = result.map { it.scheme }.toSet() + assertTrue(schemes.contains("http"), "Should contain http scheme") + assertTrue(schemes.contains("https"), "Should contain https scheme") + } + + @Test + fun `parse should detect autoVerify flag`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + AutoVerify=true + Scheme: "https" + Authority: "example.com": -1 + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertTrue(result[0].autoVerify, "Should detect AutoVerify flag") + } + + @Test + fun `parse should extract path information`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Path: "PatternMatcher{LITERAL: /products}" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertEquals("/products", result[0].path) + } + + @Test + fun `parse should extract path prefix`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Path: "PatternMatcher{PREFIX: /products/}" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertEquals("/products/", result[0].pathPrefix) + } + + @Test + fun `parse should skip non-VIEW filters`() { + val output = """ + Activity Resolver Table: + Schemes: + custom: + abc1234 com.example.app/.OtherActivity filter def5678 + Action: "android.intent.action.SEND" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "custom" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertTrue(result.isEmpty(), "Non-VIEW action should be skipped") + } + + @Test + fun `parse should skip non-BROWSABLE filters`() { + val output = """ + Activity Resolver Table: + Schemes: + custom: + abc1234 com.example.app/.OtherActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Scheme: "custom" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertTrue(result.isEmpty(), "Non-BROWSABLE filter should be skipped") + } + + @Test + fun `parse should handle multiple activities`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + ghi9012 com.example.app/.DeepLinkActivity filter jkl3456 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "other.example.com": -1 + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(2, result.size, "Should find two registrations from different activities") + + val activities = result.map { it.activityName }.toSet() + assertTrue(activities.contains("com.example.app/.MainActivity")) + assertTrue(activities.contains("com.example.app/.DeepLinkActivity")) + } + + @Test + fun `parse should handle custom scheme without host`() { + val output = """ + Activity Resolver Table: + Schemes: + myapp: + abc1234 com.example.app/.DeepLinkActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "myapp" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertEquals("myapp", result[0].scheme) + assertEquals(null, result[0].host) + } + + @Test + fun `parse should produce correct uriPattern`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Path: "PatternMatcher{LITERAL: /products}" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertEquals("https://example.com/products", result[0].uriPattern) + } + + @Test + fun `parse should produce correct collisionKey`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertEquals("https://example.com", result[0].collisionKey) + } + + @Test + fun `parse should handle scheme and path combinations`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "http" + Scheme: "https" + Authority: "example.com": -1 + Path: "PatternMatcher{LITERAL: /a}" + Path: "PatternMatcher{LITERAL: /b}" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + // 2 schemes x 2 paths = 4 registrations + assertEquals(4, result.size, "Should create combinations of schemes and paths") + } + + @Test + fun `parse should handle truncated output gracefully`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + """.trimIndent() + + // Should not crash, should extract whatever it can + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size, "Should handle truncated output") + assertEquals("https", result[0].scheme) + } + + @Test + fun `parse should handle permission denied output`() { + val result = parser.parse("com.example", "Permission denied") + // Should not crash — no Activity Resolver Table, so empty + assertTrue(result.isEmpty(), "Permission denied should return empty list") + } + + @Test + fun `parse should handle special characters in host`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "*.example.com": -1 + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertEquals("*.example.com", result[0].host) + } + + @Test + fun `parse should handle glob path pattern`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Path: "PatternMatcher{GLOB: /products/.*}" + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size) + assertEquals("/products/.*", result[0].pathPattern) + } + + @Test + fun `parse should handle duplicate registrations across filters`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + abc1234 com.example.app/.MainActivity filter ghi9012 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Receiver Resolver Table: + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(2, result.size, "Should include both filter registrations even if duplicate") + } + + @Test + fun `parse should stop at Receiver Resolver Table boundary`() { + val output = """ + Activity Resolver Table: + Schemes: + https: + abc1234 com.example.app/.MainActivity filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "example.com": -1 + Receiver Resolver Table: + Schemes: + https: + abc1234 com.example.receiver/.MyReceiver filter def5678 + Action: "android.intent.action.VIEW" + Category: "android.intent.category.DEFAULT" + Category: "android.intent.category.BROWSABLE" + Scheme: "https" + Authority: "other.com": -1 + """.trimIndent() + + val result = parser.parse("com.example.app", output) + assertEquals(1, result.size, "Should only parse Activity Resolver Table section") + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/diagnostics/DetectCollisionsUseCaseTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/diagnostics/DetectCollisionsUseCaseTest.kt new file mode 100644 index 0000000..23334f7 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/diagnostics/DetectCollisionsUseCaseTest.kt @@ -0,0 +1,230 @@ +package com.manjee.linkops.domain.usecase.diagnostics + +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 kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Fake CollisionRepository for testing + */ +class FakeCollisionRepository : CollisionRepository { + var resultToReturn: Result = Result.success( + CollisionReport( + collisionGroups = emptyList(), + totalRegistrations = 0, + scannedPackages = 0 + ) + ) + + var lastDeviceSerial: String? = null + + override suspend fun detectCollisions(deviceSerial: String): Result { + lastDeviceSerial = deviceSerial + return resultToReturn + } +} + +class DetectCollisionsUseCaseTest { + + private val fakeRepository = FakeCollisionRepository() + private val useCase = DetectCollisionsUseCase(fakeRepository) + + @Test + fun `invoke should delegate to repository with correct device serial`() = runTest { + useCase("device-123") + assertEquals("device-123", fakeRepository.lastDeviceSerial) + } + + @Test + fun `invoke should return success with collision report`() = runTest { + val registrations = listOf( + SchemeRegistration( + packageName = "com.app.one", + activityName = "com.app.one/.MainActivity", + scheme = "https", + host = "example.com", + path = null, + pathPrefix = null, + pathPattern = null, + autoVerify = true + ), + SchemeRegistration( + packageName = "com.app.two", + activityName = "com.app.two/.DeepLinkActivity", + scheme = "https", + host = "example.com", + path = null, + pathPrefix = null, + pathPattern = null, + autoVerify = false + ) + ) + + val expectedReport = CollisionReport( + collisionGroups = listOf( + CollisionGroup( + pattern = "https://example.com", + severity = CollisionSeverity.CRITICAL, + registrations = registrations + ) + ), + totalRegistrations = 5, + scannedPackages = 10 + ) + + fakeRepository.resultToReturn = Result.success(expectedReport) + + val result = useCase("device-123") + assertTrue(result.isSuccess, "Should return success") + + val report = result.getOrNull()!! + assertEquals(1, report.collisionGroups.size) + assertEquals(1, report.criticalCount) + assertEquals(0, report.warningCount) + assertTrue(report.hasCollisions) + assertEquals(10, report.scannedPackages) + assertEquals(5, report.totalRegistrations) + } + + @Test + fun `invoke should return failure when repository fails`() = runTest { + fakeRepository.resultToReturn = Result.failure(RuntimeException("ADB connection lost")) + + val result = useCase("device-123") + assertTrue(result.isFailure, "Should return failure") + assertEquals("ADB connection lost", result.exceptionOrNull()?.message) + } + + @Test + fun `invoke should return report with no collisions`() = runTest { + val emptyReport = CollisionReport( + collisionGroups = emptyList(), + totalRegistrations = 20, + scannedPackages = 15 + ) + fakeRepository.resultToReturn = Result.success(emptyReport) + + val result = useCase("device-123") + assertTrue(result.isSuccess) + + val report = result.getOrNull()!! + assertTrue(!report.hasCollisions, "Should have no collisions") + assertEquals(0, report.criticalCount) + assertEquals(0, report.warningCount) + assertEquals(15, report.scannedPackages) + assertEquals(20, report.totalRegistrations) + } + + @Test + fun `collision report should correctly count critical and warning groups`() = runTest { + val report = CollisionReport( + collisionGroups = listOf( + CollisionGroup( + pattern = "https://example.com", + severity = CollisionSeverity.CRITICAL, + registrations = listOf( + SchemeRegistration("com.a", "com.a/.A", "https", "example.com", null, null, null), + SchemeRegistration("com.b", "com.b/.B", "https", "example.com", null, null, null) + ) + ), + CollisionGroup( + pattern = "myapp://*", + severity = CollisionSeverity.WARNING, + registrations = listOf( + SchemeRegistration("com.c", "com.c/.C", "myapp", null, null, null, null), + SchemeRegistration("com.d", "com.d/.D", "myapp", null, "/x", null, null) + ) + ), + CollisionGroup( + pattern = "https://test.com", + severity = CollisionSeverity.CRITICAL, + registrations = listOf( + SchemeRegistration("com.e", "com.e/.E", "https", "test.com", null, null, null), + SchemeRegistration("com.f", "com.f/.F", "https", "test.com", null, null, null) + ) + ) + ), + totalRegistrations = 50, + scannedPackages = 25 + ) + + fakeRepository.resultToReturn = Result.success(report) + + val result = useCase("device-123") + val actual = result.getOrNull()!! + assertEquals(2, actual.criticalCount, "Should have 2 critical groups") + assertEquals(1, actual.warningCount, "Should have 1 warning group") + assertTrue(actual.hasCollisions) + } + + @Test + fun `collision group should report correct app count and package names`() { + val group = CollisionGroup( + pattern = "https://example.com", + severity = CollisionSeverity.CRITICAL, + registrations = listOf( + SchemeRegistration("com.a", "com.a/.A1", "https", "example.com", null, null, null), + SchemeRegistration("com.a", "com.a/.A2", "https", "example.com", "/path", null, null), + SchemeRegistration("com.b", "com.b/.B1", "https", "example.com", null, null, null) + ) + ) + + assertEquals(2, group.appCount, "Should count distinct packages") + assertEquals(listOf("com.a", "com.b"), group.packageNames) + } + + @Test + fun `collision group should detect verified apps`() { + val groupWithVerified = CollisionGroup( + pattern = "https://example.com", + severity = CollisionSeverity.CRITICAL, + registrations = listOf( + SchemeRegistration("com.a", "com.a/.A", "https", "example.com", null, null, null, autoVerify = true), + SchemeRegistration("com.b", "com.b/.B", "https", "example.com", null, null, null, autoVerify = false) + ) + ) + + assertTrue(groupWithVerified.hasVerifiedApp, "Should detect verified app") + + val groupWithoutVerified = CollisionGroup( + pattern = "myapp://*", + severity = CollisionSeverity.WARNING, + registrations = listOf( + SchemeRegistration("com.a", "com.a/.A", "myapp", null, null, null, null, autoVerify = false), + SchemeRegistration("com.b", "com.b/.B", "myapp", null, null, null, null, autoVerify = false) + ) + ) + + assertTrue(!groupWithoutVerified.hasVerifiedApp, "Should not detect verified app") + } + + @Test + fun `scheme registration should generate correct uri pattern`() { + val reg1 = SchemeRegistration("com.a", "com.a/.A", "https", "example.com", "/products", null, null) + assertEquals("https://example.com/products", reg1.uriPattern) + + val reg2 = SchemeRegistration("com.a", "com.a/.A", "https", "example.com", null, "/api/", null) + assertEquals("https://example.com/api/*", reg2.uriPattern) + + val reg3 = SchemeRegistration("com.a", "com.a/.A", "myapp", null, null, null, null) + assertEquals("myapp://*/*", reg3.uriPattern) + + val reg4 = SchemeRegistration("com.a", "com.a/.A", "https", "example.com", null, null, "/.*") + assertEquals("https://example.com/.*", reg4.uriPattern) + } + + @Test + fun `scheme registration should generate correct collision key`() { + val reg1 = SchemeRegistration("com.a", "com.a/.A", "https", "example.com", "/products", null, null) + assertEquals("https://example.com", reg1.collisionKey) + + val reg2 = SchemeRegistration("com.a", "com.a/.A", "myapp", null, null, null, null) + assertEquals("myapp://*", reg2.collisionKey) + } +}