diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt index 13fa19e..922dd63 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Terminal @@ -34,6 +35,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.sniffer.IntentSnifferScreen +import com.manjee.linkops.ui.screen.sniffer.IntentSnifferViewModel import com.manjee.linkops.ui.theme.LinkOpsTheme import org.jetbrains.compose.ui.tooling.preview.Preview @@ -64,6 +67,7 @@ fun App() { val logStreamViewModel = remember { LogStreamViewModel() } val batchTestViewModel = remember { BatchTestViewModel() } val localHostingViewModel = remember { LocalHostingViewModel() } + val intentSnifferViewModel = remember { IntentSnifferViewModel() } val keyboardShortcutHandler = remember { KeyboardShortcutHandler() } val searchFocusTrigger = remember { mutableStateOf(0) } @@ -79,6 +83,7 @@ fun App() { logStreamViewModel.onCleared() batchTestViewModel.onCleared() localHostingViewModel.onCleared() + intentSnifferViewModel.onCleared() } } @@ -184,6 +189,14 @@ fun App() { ) } + Screen.IntentSniffer -> { + val mainUiState by mainViewModel.uiState.collectAsState() + IntentSnifferScreen( + viewModel = intentSnifferViewModel, + devices = mainUiState.devices + ) + } + Screen.Settings -> { // TODO: Implement SettingsScreen MainScreen(viewModel = mainViewModel) @@ -273,6 +286,13 @@ private fun NavigationSidebar( onClick = { onNavigate(Screen.LocalHosting) } ) + NavigationRailItem( + icon = { Icon(Icons.Default.BugReport, contentDescription = "Sniffer") }, + label = { Text("Sniffer") }, + selected = currentScreen == Screen.IntentSniffer, + onClick = { onNavigate(Screen.IntentSniffer) } + ) + Spacer(modifier = Modifier.weight(1f)) NavigationRailItem( diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/IntentPayloadParser.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/IntentPayloadParser.kt new file mode 100644 index 0000000..fa8dce0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/IntentPayloadParser.kt @@ -0,0 +1,229 @@ +package com.manjee.linkops.data.parser + +import com.manjee.linkops.domain.model.BundleExtra +import com.manjee.linkops.domain.model.BundleExtraType +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.model.ParsingMethod + +/** + * Parser for `dumpsys activity activities` output to extract intent payload + * + * Uses a two-stage parsing strategy: + * - Stage 1 (Regex): Extracts intent fields using regex patterns + * - Stage 2 (Simple Split): Fallback when regex fails + * + * Example output: + * ``` + * intent={act=android.intent.action.VIEW dat=https://example.com/path + * flg=0x10000000 cmp=com.example/.MainActivity (has extras)} + * Extras: + * referrer = "com.android.shell" (String) + * extra_id = 42 (Integer) + * ``` + */ +class IntentPayloadParser { + + private val intentBlockRegex = Regex("""intent=\{(.+?)\}""", RegexOption.DOT_MATCHES_ALL) + private val actionRegex = Regex("""act=(\S+)""") + private val dataRegex = Regex("""dat=(\S+)""") + private val flagsRegex = Regex("""flg=0x([0-9a-fA-F]+)""") + private val componentRegex = Regex("""cmp=(\S+?)(?:\s|\}|$)""") + private val categoriesRegex = Regex("""cat=\[([^\]]*)\]""") + private val extrasLineRegex = Regex("""^\s+(\S+)\s*=\s*(.+?)\s*\((\w+)\)\s*$""") + + /** + * Parses dumpsys activity output to extract intent payload + * + * @param dumpsysOutput Raw output from `dumpsys activity activities` + * @return IntentPayload with parsed fields, or raw-only payload if parsing fails + */ + fun parse(dumpsysOutput: String): IntentPayload { + if (dumpsysOutput.isBlank()) { + return createRawPayload(dumpsysOutput) + } + + // Stage 1: Try regex parsing + val regexResult = parseWithRegex(dumpsysOutput) + if (regexResult != null) return regexResult + + // Stage 2: Fallback to simple split parsing + val splitResult = parseWithSplit(dumpsysOutput) + if (splitResult != null) return splitResult + + // Stage 3: Raw only + return createRawPayload(dumpsysOutput) + } + + private fun parseWithRegex(output: String): IntentPayload? { + val intentMatch = intentBlockRegex.find(output) ?: return null + val intentContent = intentMatch.groupValues[1] + + val action = actionRegex.find(intentContent)?.groupValues?.get(1) + val dataUri = dataRegex.find(intentContent)?.groupValues?.get(1) + val flags = flagsRegex.find(intentContent)?.groupValues?.get(1) + ?.toIntOrNull(16) ?: 0 + val component = componentRegex.find(intentContent)?.groupValues?.get(1) + ?.removeSuffix("}") + val categories = categoriesRegex.find(intentContent)?.groupValues?.get(1) + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?: emptyList() + + val extras = parseExtras(output) + + return IntentPayload( + action = action, + dataUri = dataUri, + categories = categories, + flags = flags, + component = component, + extras = extras, + rawOutput = output, + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.REGEX + ) + } + + private fun parseWithSplit(output: String): IntentPayload? { + val lines = output.lines() + var action: String? = null + var dataUri: String? = null + var flags = 0 + var component: String? = null + val categories = mutableListOf() + var foundIntent = false + + for (line in lines) { + val trimmed = line.trim() + + if (trimmed.contains("intent={") || trimmed.contains("Intent {")) { + foundIntent = true + val parts = trimmed.split(" ") + for (part in parts) { + when { + part.startsWith("act=") -> action = part.removePrefix("act=") + part.startsWith("dat=") -> dataUri = part.removePrefix("dat=") + part.startsWith("flg=0x") -> { + flags = part.removePrefix("flg=0x") + .trimEnd('}', ' ') + .toIntOrNull(16) ?: 0 + } + part.startsWith("cmp=") -> { + component = part.removePrefix("cmp=").trimEnd('}', ' ') + } + part.startsWith("cat=") -> { + val catContent = part.removePrefix("cat=") + .removePrefix("[") + .removeSuffix("]") + .trim() + if (catContent.isNotEmpty()) { + categories.addAll(catContent.split(",").map { it.trim() }) + } + } + } + } + } + } + + if (!foundIntent) return null + + val extras = parseExtras(output) + + return IntentPayload( + action = action, + dataUri = dataUri, + categories = categories, + flags = flags, + component = component, + extras = extras, + rawOutput = output, + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.SIMPLE_SPLIT + ) + } + + private fun parseExtras(output: String): Map { + val extras = mutableMapOf() + val lines = output.lines() + var inExtrasSection = false + + for (line in lines) { + val trimmed = line.trim() + + if (trimmed.startsWith("Extras:") || trimmed.startsWith("extras:") || + trimmed.contains("Bundle[{") + ) { + inExtrasSection = true + // Handle inline Bundle[{key=value, key=value}] + if (trimmed.contains("Bundle[{")) { + parseBundleInline(trimmed, extras) + inExtrasSection = false + } + continue + } + + if (inExtrasSection) { + // Exit extras section when encountering non-extra content + if (trimmed.isEmpty() || trimmed.startsWith("intent=") || + (!trimmed.contains("=") && !trimmed.matches(Regex("""^\s+\S+\s*=.*"""))) + ) { + inExtrasSection = false + continue + } + + val match = extrasLineRegex.matchEntire(line) + if (match != null) { + val key = match.groupValues[1] + val value = match.groupValues[2].trim().removeSurrounding("\"") + val typeStr = match.groupValues[3] + val type = parseExtraType(typeStr) + extras[key] = BundleExtra(key = key, value = value, type = type) + } + } + } + + return extras + } + + private fun parseBundleInline(line: String, extras: MutableMap) { + val bundleContent = line.substringAfter("Bundle[{").substringBefore("}]") + if (bundleContent.isBlank()) return + + bundleContent.split(",").forEach { pair -> + val parts = pair.trim().split("=", limit = 2) + if (parts.size == 2) { + val key = parts[0].trim() + val value = parts[1].trim() + extras[key] = BundleExtra(key = key, value = value, type = BundleExtraType.UNKNOWN) + } + } + } + + private fun parseExtraType(typeString: String): BundleExtraType { + return when (typeString.lowercase()) { + "string", "java.lang.string" -> BundleExtraType.STRING + "int", "integer", "java.lang.integer" -> BundleExtraType.INT + "long", "java.lang.long" -> BundleExtraType.LONG + "float", "java.lang.float" -> BundleExtraType.FLOAT + "boolean", "java.lang.boolean" -> BundleExtraType.BOOLEAN + "bundle", "android.os.bundle" -> BundleExtraType.BUNDLE + "parcelable" -> BundleExtraType.PARCELABLE + else -> BundleExtraType.UNKNOWN + } + } + + private fun createRawPayload(output: String): IntentPayload { + return IntentPayload( + action = null, + dataUri = null, + categories = emptyList(), + flags = 0, + component = null, + extras = emptyMap(), + rawOutput = output, + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.RAW_ONLY + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/IntentPayloadRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/IntentPayloadRepositoryImpl.kt new file mode 100644 index 0000000..372238d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/IntentPayloadRepositoryImpl.kt @@ -0,0 +1,105 @@ +package com.manjee.linkops.data.repository + +import com.manjee.linkops.data.parser.IntentPayloadParser +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.repository.IntentPayloadRepository +import com.manjee.linkops.infrastructure.adb.AdbShellExecutor +import kotlinx.coroutines.delay + +/** + * Implementation of IntentPayloadRepository using ADB commands + * + * Captures intent payloads by executing `dumpsys activity activities` and parsing + * the topmost activity's intent information. Supports top activity change detection + * via polling `dumpsys activity top`. + * + * @param adbExecutor ADB shell command executor + * @param parser Parser for dumpsys activity output + */ +class IntentPayloadRepositoryImpl( + private val adbExecutor: AdbShellExecutor, + private val parser: IntentPayloadParser +) : IntentPayloadRepository { + + override suspend fun captureCurrentPayload(deviceSerial: String): Result { + return adbExecutor.executeOnDevice(deviceSerial, DUMPSYS_ACTIVITIES_COMMAND) + .mapCatching { output -> + val topActivityOutput = extractTopActivitySection(output) + parser.parse(topActivityOutput) + } + } + + override suspend fun captureAfterActivityChange( + deviceSerial: String, + timeoutMs: Long + ): Result { + // Record current top activity + val currentTopActivity = adbExecutor.executeOnDevice(deviceSerial, DUMPSYS_TOP_COMMAND) + .getOrElse { return Result.failure(it) } + + val currentTopId = extractTopActivityIdentifier(currentTopActivity) + + // Poll for activity change + val startTime = System.currentTimeMillis() + while (System.currentTimeMillis() - startTime < timeoutMs) { + delay(IntentPayloadRepository.POLLING_INTERVAL_MS) + + val newTopActivity = adbExecutor.executeOnDevice(deviceSerial, DUMPSYS_TOP_COMMAND) + .getOrElse { continue } + + val newTopId = extractTopActivityIdentifier(newTopActivity) + + if (newTopId != currentTopId) { + // Top activity changed, capture the new payload + return captureCurrentPayload(deviceSerial) + } + } + + // Timeout reached, capture current state as fallback + return captureCurrentPayload(deviceSerial) + } + + /** + * Extracts the top activity section from full dumpsys activities output + * + * Looks for the "Running activities" or "Hist #0" section which contains + * the topmost activity's intent data. + */ + private fun extractTopActivitySection(fullOutput: String): String { + val lines = fullOutput.lines() + + // Find the first "Hist #0" entry which is the topmost activity + val histIndex = lines.indexOfFirst { it.trim().startsWith("Hist #0") } + if (histIndex < 0) return fullOutput + + // Collect lines from Hist #0 until the next Hist entry or section end + val sectionLines = mutableListOf() + for (i in histIndex until lines.size) { + val line = lines[i] + if (i > histIndex && (line.trim().startsWith("Hist #") || line.trim().startsWith("Task id"))) { + break + } + sectionLines.add(line) + } + + return sectionLines.joinToString("\n") + } + + /** + * Extracts a unique identifier for the current top activity + * + * Looks for the ACTIVITY line or first meaningful content from dumpsys activity top + */ + private fun extractTopActivityIdentifier(topOutput: String): String { + val activityLine = topOutput.lines().firstOrNull { line -> + line.trim().startsWith("ACTIVITY") || line.trim().startsWith("Hist #0") + } + return activityLine?.trim() ?: topOutput.take(TOP_ACTIVITY_ID_LENGTH) + } + + companion object { + private const val DUMPSYS_ACTIVITIES_COMMAND = "dumpsys activity activities" + private const val DUMPSYS_TOP_COMMAND = "dumpsys activity top" + private const val TOP_ACTIVITY_ID_LENGTH = 200 + } +} 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 6850bc6..4ed7448 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -12,6 +12,7 @@ import com.manjee.linkops.data.parser.DumpsysParser import com.manjee.linkops.data.parser.FingerprintParser import com.manjee.linkops.data.parser.GetAppLinksParser import com.manjee.linkops.data.parser.IntentFilterParser +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.AppLinkRepositoryImpl @@ -20,6 +21,7 @@ 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.IntentPayloadRepositoryImpl import com.manjee.linkops.data.repository.LocalHostingRepositoryImpl import com.manjee.linkops.data.repository.LogStreamRepositoryImpl import com.manjee.linkops.data.repository.ManifestRepositoryImpl @@ -31,6 +33,7 @@ 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.IntentPayloadRepository import com.manjee.linkops.domain.repository.LocalHostingRepository import com.manjee.linkops.domain.repository.LogStreamRepository import com.manjee.linkops.domain.repository.ManifestRepository @@ -55,16 +58,22 @@ import com.manjee.linkops.domain.usecase.localhosting.RunVerificationWorkflowUse import com.manjee.linkops.domain.usecase.localhosting.StartLocalServerUseCase import com.manjee.linkops.domain.usecase.localhosting.StopLocalServerUseCase import com.manjee.linkops.domain.usecase.logstream.ObserveLogStreamUseCase +import com.manjee.linkops.domain.usecase.sniffer.CaptureIntentPayloadUseCase +import com.manjee.linkops.domain.usecase.sniffer.ComparePayloadsUseCase import com.manjee.linkops.domain.usecase.manifest.AnalyzeManifestUseCase import com.manjee.linkops.domain.usecase.topology.BuildTopologyTreeUseCase import com.manjee.linkops.domain.usecase.manifest.GetInstalledPackagesUseCase import com.manjee.linkops.domain.usecase.manifest.SearchPackagesUseCase 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.network.AssetLinksClient import com.manjee.linkops.infrastructure.qr.QrCodeGenerator import com.manjee.linkops.infrastructure.server.AssetLinksServer +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow /** * Simple dependency injection container @@ -141,6 +150,10 @@ object AppContainer { FingerprintParser() } + private val intentPayloadParser: IntentPayloadParser by lazy { + IntentPayloadParser() + } + // Data - Generators private val assetLinksGenerator: AssetLinksGenerator by lazy { AssetLinksGenerator() @@ -220,6 +233,10 @@ object AppContainer { CollisionRepositoryImpl(adbShellExecutor, intentFilterParser) } + val intentPayloadRepository: IntentPayloadRepository by lazy { + IntentPayloadRepositoryImpl(adbShellExecutor, intentPayloadParser) + } + // UseCases - Device val detectDevicesUseCase: DetectDevicesUseCase by lazy { DetectDevicesUseCase(deviceRepository) @@ -328,4 +345,24 @@ object AppContainer { val resolveTemplateUrisUseCase: ResolveTemplateUrisUseCase by lazy { ResolveTemplateUrisUseCase(parameterSubstituter) } + + // UseCases - Intent Sniffer + val captureIntentPayloadUseCase: CaptureIntentPayloadUseCase by lazy { + CaptureIntentPayloadUseCase(intentPayloadRepository) + } + + val comparePayloadsUseCase: ComparePayloadsUseCase by lazy { + ComparePayloadsUseCase() + } + + // Events - Intent Sniffer + private val _intentFiredEvent = MutableSharedFlow(extraBufferCapacity = 1) + val intentFiredEvent: SharedFlow = _intentFiredEvent.asSharedFlow() + + /** + * Emits an intent fired event for auto-capture in Intent Sniffer + */ + fun emitIntentFired(event: IntentFiredEvent) { + _intentFiredEvent.tryEmit(event) + } } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/IntentPayload.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/IntentPayload.kt new file mode 100644 index 0000000..eb0e139 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/IntentPayload.kt @@ -0,0 +1,69 @@ +package com.manjee.linkops.domain.model + +/** + * Represents a captured intent payload from an Android activity + * + * Contains all extracted fields from `dumpsys activity activities` output + * including action, data URI, flags, component, and extras bundle. + */ +data class IntentPayload( + val action: String?, + val dataUri: String?, + val categories: List, + val flags: Int, + val component: String?, + val extras: Map, + val rawOutput: String, + val timestamp: Long, + val parsingMethod: ParsingMethod +) { + val displayName: String + get() = dataUri ?: action ?: "Unknown Intent" + + val hasExtras: Boolean + get() = extras.isNotEmpty() +} + +/** + * Represents a single key-value pair from an intent's extras Bundle + */ +data class BundleExtra( + val key: String, + val value: String?, + val type: BundleExtraType +) + +/** + * Supported Bundle extra value types + */ +enum class BundleExtraType { + STRING, INT, LONG, FLOAT, BOOLEAN, BUNDLE, PARCELABLE, UNKNOWN +} + +/** + * Result of comparing two intent payloads + */ +data class PayloadComparison( + val addedExtras: Map, + val removedExtras: Map, + val changedExtras: Map>, + val unchangedExtras: Map +) + +/** + * Indicates which parsing strategy was used to extract intent data + */ +enum class ParsingMethod { + REGEX, SIMPLE_SPLIT, RAW_ONLY +} + +/** + * Event emitted when an intent is fired from the main screen + * + * @param deviceSerial Serial number of the target device + * @param uri The deep link URI that was fired + */ +data class IntentFiredEvent( + val deviceSerial: String, + val uri: String +) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/IntentPayloadRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/IntentPayloadRepository.kt new file mode 100644 index 0000000..21e4c79 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/IntentPayloadRepository.kt @@ -0,0 +1,40 @@ +package com.manjee.linkops.domain.repository + +import com.manjee.linkops.domain.model.IntentPayload + +/** + * Repository for capturing and analyzing intent payloads from Android devices + */ +interface IntentPayloadRepository { + + /** + * Captures the current top activity's intent payload from a device + * + * Executes `dumpsys activity activities` on the target device and parses + * the topmost activity's intent information. + * + * @param deviceSerial Serial number of the target device + * @return Result containing the parsed IntentPayload or an error + */ + suspend fun captureCurrentPayload(deviceSerial: String): Result + + /** + * Captures intent payload after detecting a top activity change + * + * Uses polling to detect when the top activity changes (after an intent is fired), + * then captures the new activity's intent payload. + * + * @param deviceSerial Serial number of the target device + * @param timeoutMs Maximum time to wait for activity change in milliseconds + * @return Result containing the parsed IntentPayload or an error + */ + suspend fun captureAfterActivityChange( + deviceSerial: String, + timeoutMs: Long = DEFAULT_POLLING_TIMEOUT_MS + ): Result + + companion object { + const val DEFAULT_POLLING_TIMEOUT_MS = 3000L + const val POLLING_INTERVAL_MS = 100L + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/sniffer/CaptureIntentPayloadUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/sniffer/CaptureIntentPayloadUseCase.kt new file mode 100644 index 0000000..3952e1b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/sniffer/CaptureIntentPayloadUseCase.kt @@ -0,0 +1,37 @@ +package com.manjee.linkops.domain.usecase.sniffer + +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.repository.IntentPayloadRepository + +/** + * Captures the current top activity's intent payload from a device + * + * @param intentPayloadRepository Repository for intent payload operations + */ +class CaptureIntentPayloadUseCase( + private val intentPayloadRepository: IntentPayloadRepository +) { + /** + * Captures intent payload from the current top activity + * + * @param deviceSerial Serial number of the target device + * @return Result containing the parsed IntentPayload or an error + */ + suspend operator fun invoke(deviceSerial: String): Result { + return intentPayloadRepository.captureCurrentPayload(deviceSerial) + } + + /** + * Captures intent payload after detecting a top activity change + * + * @param deviceSerial Serial number of the target device + * @param timeoutMs Maximum time to wait for activity change + * @return Result containing the parsed IntentPayload or an error + */ + suspend fun captureAfterChange( + deviceSerial: String, + timeoutMs: Long = IntentPayloadRepository.DEFAULT_POLLING_TIMEOUT_MS + ): Result { + return intentPayloadRepository.captureAfterActivityChange(deviceSerial, timeoutMs) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/sniffer/ComparePayloadsUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/sniffer/ComparePayloadsUseCase.kt new file mode 100644 index 0000000..dad76f8 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/sniffer/ComparePayloadsUseCase.kt @@ -0,0 +1,38 @@ +package com.manjee.linkops.domain.usecase.sniffer + +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.model.PayloadComparison + +/** + * Compares two intent payloads and produces a diff of their extras + */ +class ComparePayloadsUseCase { + + /** + * Compares extras between two intent payloads + * + * @param oldPayload The baseline payload + * @param newPayload The payload to compare against + * @return PayloadComparison containing added, removed, changed, and unchanged extras + */ + operator fun invoke(oldPayload: IntentPayload, newPayload: IntentPayload): PayloadComparison { + val oldExtras = oldPayload.extras + val newExtras = newPayload.extras + + val addedExtras = newExtras.filterKeys { it !in oldExtras } + val removedExtras = oldExtras.filterKeys { it !in newExtras } + val unchangedExtras = oldExtras.filterKeys { key -> + key in newExtras && oldExtras[key] == newExtras[key] + } + val changedExtras = oldExtras.filterKeys { key -> + key in newExtras && oldExtras[key] != newExtras[key] + }.mapValues { (key, oldValue) -> Pair(oldValue, newExtras.getValue(key)) } + + return PayloadComparison( + addedExtras = addedExtras, + removedExtras = removedExtras, + changedExtras = changedExtras, + unchangedExtras = unchangedExtras + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/JsonTreeView.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/JsonTreeView.kt new file mode 100644 index 0000000..8dc8c34 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/JsonTreeView.kt @@ -0,0 +1,206 @@ +package com.manjee.linkops.ui.component + +import androidx.compose.foundation.background +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 com.manjee.linkops.domain.model.BundleExtra +import com.manjee.linkops.domain.model.BundleExtraType +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.model.ParsingMethod +import com.manjee.linkops.ui.theme.LinkOpsColors + +/** + * Displays intent payload fields and extras in a structured tree view + * + * @param intentPayload The intent payload to display + * @param modifier Modifier for the component + */ +@Composable +fun JsonTreeView( + intentPayload: IntentPayload, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Parsing method badge + item { + ParsingMethodBadge(intentPayload.parsingMethod) + } + + // Intent fields + item { + Text( + text = "Intent Fields", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + + item { FieldRow("action", intentPayload.action ?: "(none)") } + item { FieldRow("data", intentPayload.dataUri ?: "(none)") } + item { FieldRow("flags", "0x${intentPayload.flags.toString(16).uppercase()}") } + item { FieldRow("component", intentPayload.component ?: "(none)") } + + if (intentPayload.categories.isNotEmpty()) { + item { + FieldRow("categories", intentPayload.categories.joinToString(", ")) + } + } + + // Extras section + if (intentPayload.hasExtras) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Extras (${intentPayload.extras.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + + items(intentPayload.extras.values.toList()) { extra -> + ExtraRow(extra) + } + } else { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No extras", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun FieldRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(100.dp) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun ExtraRow( + extra: BundleExtra, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = extra.key, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = extra.value ?: "(null)", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ExtraTypeBadge(extra.type) + } +} + +@Composable +private fun ExtraTypeBadge( + type: BundleExtraType, + modifier: Modifier = Modifier +) { + val (text, color) = when (type) { + BundleExtraType.STRING -> "String" to LinkOpsColors.Info + BundleExtraType.INT -> "Int" to LinkOpsColors.Success + BundleExtraType.LONG -> "Long" to LinkOpsColors.Success + BundleExtraType.FLOAT -> "Float" to LinkOpsColors.Success + BundleExtraType.BOOLEAN -> "Bool" to LinkOpsColors.Warning + BundleExtraType.BUNDLE -> "Bundle" to LinkOpsColors.Primary + BundleExtraType.PARCELABLE -> "Parcelable" to LinkOpsColors.Secondary + BundleExtraType.UNKNOWN -> "Unknown" to LinkOpsColors.Unknown + } + + Box( + modifier = modifier + .background(color.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +private fun ParsingMethodBadge( + method: ParsingMethod, + modifier: Modifier = Modifier +) { + val (text, color) = when (method) { + ParsingMethod.REGEX -> "Parsed via Regex" to LinkOpsColors.Success + ParsingMethod.SIMPLE_SPLIT -> "Parsed via Split" to LinkOpsColors.Warning + ParsingMethod.RAW_ONLY -> "Raw Only" to LinkOpsColors.Error + } + + Box( + modifier = modifier + .background(color.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.Medium + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/PayloadComparisonView.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/PayloadComparisonView.kt new file mode 100644 index 0000000..36269ee --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/PayloadComparisonView.kt @@ -0,0 +1,224 @@ +package com.manjee.linkops.ui.component + +import androidx.compose.foundation.background +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 com.manjee.linkops.domain.model.BundleExtra +import com.manjee.linkops.domain.model.PayloadComparison +import com.manjee.linkops.ui.theme.LinkOpsColors + +/** + * Displays a side-by-side diff comparison of two intent payloads + * + * @param comparison The payload comparison result + * @param modifier Modifier for the component + */ +@Composable +fun PayloadComparisonView( + comparison: PayloadComparison, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Added extras + if (comparison.addedExtras.isNotEmpty()) { + item { + DiffSectionHeader( + title = "Added (${comparison.addedExtras.size})", + color = LinkOpsColors.Success + ) + } + items(comparison.addedExtras.values.toList()) { extra -> + DiffExtraRow(extra = extra, diffType = DiffType.ADDED) + } + } + + // Removed extras + if (comparison.removedExtras.isNotEmpty()) { + item { + DiffSectionHeader( + title = "Removed (${comparison.removedExtras.size})", + color = LinkOpsColors.Error + ) + } + items(comparison.removedExtras.values.toList()) { extra -> + DiffExtraRow(extra = extra, diffType = DiffType.REMOVED) + } + } + + // Changed extras + if (comparison.changedExtras.isNotEmpty()) { + item { + DiffSectionHeader( + title = "Changed (${comparison.changedExtras.size})", + color = LinkOpsColors.Warning + ) + } + items(comparison.changedExtras.entries.toList()) { (_, pair) -> + ChangedExtraRow(oldExtra = pair.first, newExtra = pair.second) + } + } + + // Unchanged extras + if (comparison.unchangedExtras.isNotEmpty()) { + item { + DiffSectionHeader( + title = "Unchanged (${comparison.unchangedExtras.size})", + color = LinkOpsColors.Unknown + ) + } + items(comparison.unchangedExtras.values.toList()) { extra -> + DiffExtraRow(extra = extra, diffType = DiffType.UNCHANGED) + } + } + + // Empty state + if (comparison.addedExtras.isEmpty() && comparison.removedExtras.isEmpty() && + comparison.changedExtras.isEmpty() && comparison.unchangedExtras.isEmpty() + ) { + item { + Text( + text = "Both payloads have no extras to compare", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +private enum class DiffType { + ADDED, REMOVED, UNCHANGED +} + +@Composable +private fun DiffSectionHeader( + title: String, + color: androidx.compose.ui.graphics.Color, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(color, RoundedCornerShape(2.dp)) + ) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = color + ) + } +} + +@Composable +private fun DiffExtraRow( + extra: BundleExtra, + diffType: DiffType, + modifier: Modifier = Modifier +) { + val (bgColor, prefix) = when (diffType) { + DiffType.ADDED -> LinkOpsColors.SuccessLight to "+" + DiffType.REMOVED -> LinkOpsColors.ErrorLight to "-" + DiffType.UNCHANGED -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) to " " + } + + Row( + modifier = modifier + .fillMaxWidth() + .background(bgColor, RoundedCornerShape(4.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = prefix, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.width(20.dp) + ) + Text( + text = "${extra.key} = ${extra.value ?: "(null)"}", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Text( + text = extra.type.name, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ChangedExtraRow( + oldExtra: BundleExtra, + newExtra: BundleExtra, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(LinkOpsColors.WarningLight, RoundedCornerShape(4.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = oldExtra.key, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "- ", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = LinkOpsColors.Error + ) + Text( + text = oldExtra.value ?: "(null)", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = LinkOpsColors.Error + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "+ ", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = LinkOpsColors.Success + ) + Text( + text = newExtra.value ?: "(null)", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = LinkOpsColors.Success + ) + } + } +} 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 1528df8..0c749ab 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 @@ -123,4 +123,5 @@ fun NavigationController.navigateToVerificationDeepDive() = navigateTo(Screen.Ve 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.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 9566bc4..8206bcc 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 @@ -51,6 +51,11 @@ sealed class Screen(val route: String, val title: String) { */ data object LocalHosting : Screen("local_hosting", "Local Hosting") + /** + * Intent Sniffer screen - for capturing and analyzing intent payloads + */ + data object IntentSniffer : Screen("intent_sniffer", "Intent Sniffer") + /** * Settings screen */ @@ -68,6 +73,7 @@ sealed class Screen(val route: String, val title: String) { LogStream, BatchTest, LocalHosting, + IntentSniffer, Settings ) } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt index e67c935..7e7bd51 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt @@ -6,6 +6,7 @@ import com.manjee.linkops.domain.model.AppLink import com.manjee.linkops.domain.model.Device import com.manjee.linkops.domain.model.Favorite import com.manjee.linkops.domain.model.IntentConfig +import com.manjee.linkops.domain.model.IntentFiredEvent import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -304,6 +305,13 @@ class MainViewModel { appendLog(line) } appendLog("Intent fired!") + // Emit event for auto-capture in Intent Sniffer + AppContainer.emitIntentFired( + IntentFiredEvent( + deviceSerial = device.serialNumber, + uri = config.uri + ) + ) } catch (e: Exception) { appendLog("Error: ${e.message}") } finally { diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferScreen.kt new file mode 100644 index 0000000..68795d7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferScreen.kt @@ -0,0 +1,445 @@ +package com.manjee.linkops.ui.screen.sniffer + +import androidx.compose.foundation.background +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.CameraAlt +import androidx.compose.material.icons.filled.Delete +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.manjee.linkops.domain.model.Device +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.ui.component.EmptyState +import com.manjee.linkops.ui.component.JsonTreeView +import com.manjee.linkops.ui.component.PayloadComparisonView +import com.manjee.linkops.ui.theme.LinkOpsColors +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Intent Sniffer Screen - Capture and analyze intent payloads + * + * Layout: + * - Left panel (0.35): Device selection, capture controls, history list + * - Right panel (0.65): Details/Compare/Raw tabs + */ +@Composable +fun IntentSnifferScreen( + viewModel: IntentSnifferViewModel, + devices: List, + modifier: Modifier = Modifier +) { + val uiState by viewModel.uiState.collectAsState() + + var selectedDevice by remember { mutableStateOf(null) } + + Row( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + // Left Panel - Controls & History + ControlPanel( + uiState = uiState, + devices = devices, + selectedDevice = selectedDevice, + onDeviceSelected = { selectedDevice = it }, + onCapture = { device -> viewModel.capturePayload(device) }, + onToggleAutoCapture = { viewModel.toggleAutoCapture() }, + onSelectPayload = { viewModel.selectPayload(it) }, + onSelectCompareBase = { viewModel.selectCompareBase(it) }, + onClearHistory = { viewModel.clearHistory() }, + modifier = Modifier.weight(0.35f) + ) + + // Divider + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(LinkOpsColors.Divider) + ) + + // Right Panel - Content + ContentPanel( + uiState = uiState, + onTabSelected = { viewModel.selectTab(it) }, + modifier = Modifier.weight(0.65f) + ) + } + + // Error snackbar + if (uiState.error != null) { + LaunchedEffect(uiState.error) { + kotlinx.coroutines.delay(3000) + viewModel.clearError() + } + } +} + +@Composable +private fun ControlPanel( + uiState: IntentSnifferUiState, + devices: List, + selectedDevice: Device?, + onDeviceSelected: (Device) -> Unit, + onCapture: (Device) -> Unit, + onToggleAutoCapture: () -> Unit, + onSelectPayload: (IntentPayload) -> Unit, + onSelectCompareBase: (IntentPayload) -> Unit, + onClearHistory: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Title + Text( + text = "Intent Sniffer", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + // Device Selection + DeviceSelector( + devices = devices, + selectedDevice = selectedDevice, + onDeviceSelected = onDeviceSelected + ) + + // Capture Controls + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { selectedDevice?.let { onCapture(it) } }, + enabled = selectedDevice != null && !uiState.isCapturing, + modifier = Modifier.weight(1f) + ) { + if (uiState.isCapturing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Capturing...") + } else { + Icon( + Icons.Default.CameraAlt, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Capture") + } + } + } + + // Auto-capture toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Auto-capture on intent fire", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = uiState.autoCapture, + onCheckedChange = { onToggleAutoCapture() } + ) + } + + HorizontalDivider() + + // History Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "History (${uiState.payloadHistory.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + if (uiState.payloadHistory.isNotEmpty()) { + IconButton( + onClick = onClearHistory, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Clear history", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } + + // History List + if (uiState.payloadHistory.isEmpty()) { + EmptyState( + title = "No captures yet", + description = "Select a device and capture an intent payload", + iconEmoji = null, + icon = Icons.Default.CameraAlt, + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(uiState.payloadHistory) { payload -> + PayloadHistoryItem( + payload = payload, + isSelected = payload == uiState.selectedPayload, + isCompareBase = payload == uiState.compareBasePayload, + onClick = { onSelectPayload(payload) }, + onSetAsBase = { onSelectCompareBase(payload) } + ) + } + } + } + + // Error display + if (uiState.error != null) { + Text( + text = uiState.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeviceSelector( + devices: List, + selectedDevice: Device?, + onDeviceSelected: (Device) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedDevice?.displayName ?: "Select device", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + singleLine = true, + label = { Text("Device") } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + devices.filter { it.isAvailable }.forEach { device -> + DropdownMenuItem( + text = { Text(device.displayName) }, + onClick = { + onDeviceSelected(device) + expanded = false + } + ) + } + + if (devices.none { it.isAvailable }) { + DropdownMenuItem( + text = { Text("No devices available") }, + onClick = { expanded = false }, + enabled = false + ) + } + } + } +} + +@Composable +private fun PayloadHistoryItem( + payload: IntentPayload, + isSelected: Boolean, + isCompareBase: Boolean, + onClick: () -> Unit, + onSetAsBase: () -> Unit, + modifier: Modifier = Modifier +) { + val timeFormat = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } + val bgColor = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer + isCompareBase -> LinkOpsColors.InfoLight + else -> MaterialTheme.colorScheme.surface + } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .background(bgColor, RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = payload.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = timeFormat.format(Date(payload.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Compare base marker + if (isCompareBase) { + Box( + modifier = Modifier + .background(LinkOpsColors.Info, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "BASE", + style = MaterialTheme.typography.labelSmall, + color = LinkOpsColors.OnPrimary, + fontWeight = FontWeight.Bold + ) + } + } else { + TextButton( + onClick = onSetAsBase, + modifier = Modifier.height(28.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) + ) { + Text( + text = "Set Base", + style = MaterialTheme.typography.labelSmall + ) + } + } + } +} + +@Composable +private fun ContentPanel( + uiState: IntentSnifferUiState, + onTabSelected: (SnifferTab) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxHeight() + .background(MaterialTheme.colorScheme.background) + ) { + // Tab Row + TabRow( + selectedTabIndex = uiState.selectedTab.ordinal, + containerColor = MaterialTheme.colorScheme.surface + ) { + SnifferTab.entries.forEach { tab -> + Tab( + selected = uiState.selectedTab == tab, + onClick = { onTabSelected(tab) }, + text = { Text(tab.name.lowercase().replaceFirstChar { it.uppercase() }) } + ) + } + } + + // Tab Content + val selectedPayload = uiState.selectedPayload + if (selectedPayload == null) { + EmptyState( + title = "No payload selected", + description = "Capture an intent or select one from history", + icon = Icons.Default.CameraAlt, + iconEmoji = null, + modifier = Modifier.fillMaxSize() + ) + } else { + when (uiState.selectedTab) { + SnifferTab.DETAILS -> { + JsonTreeView( + intentPayload = selectedPayload, + modifier = Modifier.fillMaxSize() + ) + } + + SnifferTab.COMPARE -> { + val comparison = uiState.comparison + if (comparison != null) { + PayloadComparisonView( + comparison = comparison, + modifier = Modifier.fillMaxSize() + ) + } else { + EmptyState( + title = "No comparison available", + description = "Select a base payload from history to compare", + iconEmoji = null, + icon = null, + modifier = Modifier.fillMaxSize() + ) + } + } + + SnifferTab.RAW -> { + SelectionContainer { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(LinkOpsColors.TerminalBackground) + .padding(16.dp) + ) { + item { + Text( + text = selectedPayload.rawOutput, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = LinkOpsColors.TerminalText + ) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferViewModel.kt new file mode 100644 index 0000000..9f447e4 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferViewModel.kt @@ -0,0 +1,182 @@ +package com.manjee.linkops.ui.screen.sniffer + +import com.manjee.linkops.di.AppContainer +import com.manjee.linkops.domain.model.Device +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.model.PayloadComparison +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +/** + * Selected tab in the Intent Sniffer screen + */ +enum class SnifferTab { + DETAILS, COMPARE, RAW +} + +/** + * UI State for Intent Sniffer Screen + */ +data class IntentSnifferUiState( + val payloadHistory: List = emptyList(), + val selectedPayload: IntentPayload? = null, + val compareBasePayload: IntentPayload? = null, + val comparison: PayloadComparison? = null, + val selectedTab: SnifferTab = SnifferTab.DETAILS, + val isCapturing: Boolean = false, + val autoCapture: Boolean = false, + val error: String? = null +) { + val hasPayloads: Boolean get() = payloadHistory.isNotEmpty() + val canCompare: Boolean get() = compareBasePayload != null && selectedPayload != null +} + +/** + * ViewModel for Intent Sniffer Screen + * + * Manages intent payload capture, comparison, and history + */ +class IntentSnifferViewModel { + private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _uiState = MutableStateFlow(IntentSnifferUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var autoCaptureJob: Job? = null + + init { + observeIntentFiredEvents() + } + + /** + * Manually capture the current intent payload from the device + */ + fun capturePayload(device: Device) { + viewModelScope.launch { + _uiState.update { it.copy(isCapturing = true, error = null) } + + AppContainer.captureIntentPayloadUseCase(device.serialNumber) + .onSuccess { payload -> + addPayloadToHistory(payload) + } + .onFailure { error -> + _uiState.update { it.copy(isCapturing = false, error = error.message) } + } + } + } + + /** + * Capture payload after detecting activity change (used for auto-capture) + */ + private fun captureAfterChange(deviceSerial: String) { + viewModelScope.launch { + _uiState.update { it.copy(isCapturing = true, error = null) } + + AppContainer.captureIntentPayloadUseCase.captureAfterChange(deviceSerial) + .onSuccess { payload -> + addPayloadToHistory(payload) + } + .onFailure { error -> + _uiState.update { it.copy(isCapturing = false, error = error.message) } + } + } + } + + /** + * Toggle auto-capture mode + */ + fun toggleAutoCapture() { + val newState = !_uiState.value.autoCapture + _uiState.update { it.copy(autoCapture = newState) } + } + + /** + * Select a payload from history to view details + */ + fun selectPayload(payload: IntentPayload) { + _uiState.update { it.copy(selectedPayload = payload) } + updateComparison() + } + + /** + * Select a payload as the base for comparison + */ + fun selectCompareBase(payload: IntentPayload) { + _uiState.update { it.copy(compareBasePayload = payload) } + updateComparison() + } + + /** + * Switch the active tab + */ + fun selectTab(tab: SnifferTab) { + _uiState.update { it.copy(selectedTab = tab) } + } + + /** + * Clear all captured payloads + */ + fun clearHistory() { + _uiState.update { + it.copy( + payloadHistory = emptyList(), + selectedPayload = null, + compareBasePayload = null, + comparison = null + ) + } + } + + /** + * Clear error state + */ + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + /** + * Cleanup resources + */ + fun onCleared() { + viewModelScope.cancel() + } + + private fun addPayloadToHistory(payload: IntentPayload) { + _uiState.update { state -> + val updatedHistory = (listOf(payload) + state.payloadHistory).take(MAX_HISTORY_SIZE) + state.copy( + payloadHistory = updatedHistory, + selectedPayload = payload, + isCapturing = false + ) + } + updateComparison() + } + + private fun updateComparison() { + val state = _uiState.value + val base = state.compareBasePayload + val current = state.selectedPayload + + if (base != null && current != null && base != current) { + val comparison = AppContainer.comparePayloadsUseCase(base, current) + _uiState.update { it.copy(comparison = comparison) } + } else { + _uiState.update { it.copy(comparison = null) } + } + } + + private fun observeIntentFiredEvents() { + viewModelScope.launch { + AppContainer.intentFiredEvent.collect { event -> + if (_uiState.value.autoCapture) { + captureAfterChange(event.deviceSerial) + } + } + } + } + + companion object { + private const val MAX_HISTORY_SIZE = 50 + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/IntentPayloadParserTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/IntentPayloadParserTest.kt new file mode 100644 index 0000000..dada07b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/IntentPayloadParserTest.kt @@ -0,0 +1,363 @@ +package com.manjee.linkops.data.parser + +import com.manjee.linkops.domain.model.BundleExtraType +import com.manjee.linkops.domain.model.ParsingMethod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class IntentPayloadParserTest { + + private val parser = IntentPayloadParser() + + // --- Empty / Error Input Tests --- + + @Test + fun `parse should return raw-only payload for empty input`() { + val result = parser.parse("") + assertEquals(ParsingMethod.RAW_ONLY, result.parsingMethod) + assertNull(result.action) + assertNull(result.dataUri) + assertTrue(result.extras.isEmpty()) + } + + @Test + fun `parse should return raw-only payload for blank input`() { + val result = parser.parse(" \n \n ") + assertEquals(ParsingMethod.RAW_ONLY, result.parsingMethod) + assertNull(result.action) + } + + @Test + fun `parse should handle ADB error device not found`() { + val result = parser.parse("error: device not found") + assertEquals(ParsingMethod.RAW_ONLY, result.parsingMethod) + assertEquals("error: device not found", result.rawOutput) + } + + @Test + fun `parse should handle ADB error closed`() { + val result = parser.parse("error: closed") + assertEquals(ParsingMethod.RAW_ONLY, result.parsingMethod) + } + + @Test + fun `parse should handle permission denied response`() { + val result = parser.parse("Permission denied") + assertEquals(ParsingMethod.RAW_ONLY, result.parsingMethod) + } + + @Test + fun `parse should handle operation not permitted response`() { + val result = parser.parse("Operation not permitted") + assertEquals(ParsingMethod.RAW_ONLY, result.parsingMethod) + } + + // --- Regex Parsing Tests (Stage 1) --- + + @Test + fun `parse should extract action via regex`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals(ParsingMethod.REGEX, result.parsingMethod) + assertEquals("android.intent.action.VIEW", result.action) + } + + @Test + fun `parse should extract data URI via regex`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com/path?q=1 flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals("https://example.com/path?q=1", result.dataUri) + } + + @Test + fun `parse should extract flags via regex`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals(0x10000000, result.flags) + } + + @Test + fun `parse should extract component via regex`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals("com.example/.MainActivity", result.component) + } + + @Test + fun `parse should extract categories via regex`() { + val output = """ + intent={act=android.intent.action.VIEW cat=[android.intent.category.BROWSABLE,android.intent.category.DEFAULT] dat=https://example.com flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals(2, result.categories.size) + assertTrue(result.categories.contains("android.intent.category.BROWSABLE")) + assertTrue(result.categories.contains("android.intent.category.DEFAULT")) + } + + @Test + fun `parse should handle intent with extras section`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x10000000 cmp=com.example/.MainActivity (has extras)} + Extras: + referrer = "com.android.shell" (String) + extra_id = 42 (Integer) + """.trimIndent() + + val result = parser.parse(output) + assertEquals(ParsingMethod.REGEX, result.parsingMethod) + assertEquals(2, result.extras.size) + + val referrer = result.extras["referrer"] + assertNotNull(referrer) + assertEquals("com.android.shell", referrer.value) + assertEquals(BundleExtraType.STRING, referrer.type) + + val extraId = result.extras["extra_id"] + assertNotNull(extraId) + assertEquals("42", extraId.value) + assertEquals(BundleExtraType.INT, extraId.type) + } + + @Test + fun `parse should handle multiple extra types`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com cmp=com.example/.Main} + Extras: + str_val = "hello" (String) + int_val = 42 (Integer) + long_val = 123456789 (Long) + float_val = 3.14 (Float) + bool_val = true (Boolean) + bundle_val = Bundle[...] (Bundle) + parcel_val = MyParcelable@abc (Parcelable) + unknown_val = something (CustomType) + """.trimIndent() + + val result = parser.parse(output) + assertEquals(8, result.extras.size) + assertEquals(BundleExtraType.STRING, result.extras["str_val"]?.type) + assertEquals(BundleExtraType.INT, result.extras["int_val"]?.type) + assertEquals(BundleExtraType.LONG, result.extras["long_val"]?.type) + assertEquals(BundleExtraType.FLOAT, result.extras["float_val"]?.type) + assertEquals(BundleExtraType.BOOLEAN, result.extras["bool_val"]?.type) + assertEquals(BundleExtraType.BUNDLE, result.extras["bundle_val"]?.type) + assertEquals(BundleExtraType.PARCELABLE, result.extras["parcel_val"]?.type) + assertEquals(BundleExtraType.UNKNOWN, result.extras["unknown_val"]?.type) + } + + @Test + fun `parse should handle intent without data URI`() { + val output = """ + intent={act=android.intent.action.MAIN flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals("android.intent.action.MAIN", result.action) + assertNull(result.dataUri) + } + + @Test + fun `parse should handle intent without flags`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals(0, result.flags) + } + + @Test + fun `parse should handle multiline intent block`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com/very/long/path + flg=0x10000000 cmp=com.example/.MainActivity (has extras)} + """.trimIndent() + + val result = parser.parse(output) + assertEquals(ParsingMethod.REGEX, result.parsingMethod) + assertEquals("android.intent.action.VIEW", result.action) + } + + // --- Fallback Split Parsing Tests (Stage 2) --- + + @Test + fun `parse should fallback to split when regex fails on non-standard format`() { + val output = """ + Running activities (most recent first): + TaskRecord{abc #42 intent={act=android.intent.action.VIEW dat=myapp://test flg=0x10000000 cmp=com.test/.TestActivity}} + """.trimIndent() + + val result = parser.parse(output) + assertNotNull(result.action) + } + + // --- Edge Cases --- + + @Test + fun `parse should handle truncated output gracefully`() { + val output = """ + intent={act=android.intent.action.VIE + """.trimIndent() + + val result = parser.parse(output) + // Should not crash, may return raw-only or partial parse + assertNotNull(result) + assertNotNull(result.rawOutput) + } + + @Test + fun `parse should handle special characters in URI`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com/path?key=value&foo=bar%20baz#fragment flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals( + "https://example.com/path?key=value&foo=bar%20baz#fragment", + result.dataUri + ) + } + + @Test + fun `parse should handle custom scheme URIs`() { + val output = """ + intent={act=android.intent.action.VIEW dat=myapp://deep/link/path flg=0x10000000 cmp=com.example/.DeepLinkActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals("myapp://deep/link/path", result.dataUri) + } + + @Test + fun `parse should handle intent with no extras`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x10000000 cmp=com.example/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertTrue(result.extras.isEmpty()) + } + + @Test + fun `parse should preserve raw output`() { + val output = "some raw adb output content" + val result = parser.parse(output) + assertEquals(output, result.rawOutput) + } + + @Test + fun `parse should set timestamp`() { + val before = System.currentTimeMillis() + val result = parser.parse("intent={act=android.intent.action.VIEW dat=https://example.com cmp=com.example/.Main}") + val after = System.currentTimeMillis() + + assertTrue(result.timestamp in before..after) + } + + @Test + fun `parse should handle Samsung OEM dumpsys format with Intent keyword`() { + val output = """ + Running activities (most recent first): + Hist #0: ActivityRecord{abc123} + Intent { act=android.intent.action.VIEW dat=https://samsung.example.com flg=0x14000000 cmp=com.samsung.test/.DeepLinkActivity (has extras) } + Extras: + referrer = "com.android.shell" (String) + """.trimIndent() + + val result = parser.parse(output) + // Should extract at least via one of the parsing methods + assertNotNull(result) + } + + @Test + fun `parse should handle Xiaomi OEM dumpsys format`() { + val output = """ + ACTIVITY com.xiaomi.test/.MainActivity abc123 pid=1234 + intent={act=android.intent.action.VIEW dat=miui://settings flg=0x10000000 cmp=com.xiaomi.test/.MainActivity} + """.trimIndent() + + val result = parser.parse(output) + assertEquals("android.intent.action.VIEW", result.action) + assertEquals("miui://settings", result.dataUri) + } + + @Test + fun `parse should handle Bundle inline format`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x10000000 cmp=com.example/.Main} + Bundle[{key1=value1, key2=value2, key3=value3}] + """.trimIndent() + + val result = parser.parse(output) + assertEquals(3, result.extras.size) + assertEquals("value1", result.extras["key1"]?.value) + assertEquals("value2", result.extras["key2"]?.value) + assertEquals("value3", result.extras["key3"]?.value) + } + + @Test + fun `parse should handle duplicate extra keys by keeping last`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com cmp=com.example/.Main} + Extras: + key1 = "first" (String) + key1 = "second" (String) + """.trimIndent() + + val result = parser.parse(output) + // Map naturally keeps last inserted for duplicate keys + assertEquals("second", result.extras["key1"]?.value) + } + + @Test + fun `parse should handle intent with zero flags`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x0 cmp=com.example/.Main} + """.trimIndent() + + val result = parser.parse(output) + assertEquals(0, result.flags) + } + + @Test + fun `displayName should return dataUri when available`() { + val output = """ + intent={act=android.intent.action.VIEW dat=https://example.com flg=0x10000000 cmp=com.example/.Main} + """.trimIndent() + + val result = parser.parse(output) + assertEquals("https://example.com", result.displayName) + } + + @Test + fun `displayName should return action when dataUri is null`() { + val output = """ + intent={act=android.intent.action.MAIN flg=0x10000000 cmp=com.example/.Main} + """.trimIndent() + + val result = parser.parse(output) + assertEquals("android.intent.action.MAIN", result.displayName) + } + + @Test + fun `displayName should return Unknown Intent when both are null`() { + val result = parser.parse("") + assertEquals("Unknown Intent", result.displayName) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/CaptureIntentPayloadUseCaseTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/CaptureIntentPayloadUseCaseTest.kt new file mode 100644 index 0000000..ef5fbca --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/CaptureIntentPayloadUseCaseTest.kt @@ -0,0 +1,118 @@ +package com.manjee.linkops.domain.usecase.sniffer + +import com.manjee.linkops.domain.model.BundleExtra +import com.manjee.linkops.domain.model.BundleExtraType +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.model.ParsingMethod +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CaptureIntentPayloadUseCaseTest { + + private val fakeRepository = FakeIntentPayloadRepository() + private val useCase = CaptureIntentPayloadUseCase(fakeRepository) + + @Test + fun `invoke should return success when repository succeeds`() = runTest { + fakeRepository.captureResult = IntentPayload( + action = "android.intent.action.VIEW", + dataUri = "https://example.com", + categories = emptyList(), + flags = 0x10000000, + component = "com.example/.MainActivity", + extras = mapOf( + "referrer" to BundleExtra("referrer", "com.android.shell", BundleExtraType.STRING) + ), + rawOutput = "test output", + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.REGEX + ) + + val result = useCase("emulator-5554") + + assertTrue(result.isSuccess) + val payload = result.getOrNull() + assertNotNull(payload) + assertEquals("android.intent.action.VIEW", payload.action) + assertEquals("https://example.com", payload.dataUri) + assertEquals(1, payload.extras.size) + assertEquals("com.android.shell", payload.extras["referrer"]?.value) + } + + @Test + fun `invoke should return failure when repository fails`() = runTest { + fakeRepository.shouldFail = true + fakeRepository.failureException = RuntimeException("ADB not available") + + val result = useCase("emulator-5554") + + assertTrue(result.isFailure) + assertEquals("ADB not available", result.exceptionOrNull()?.message) + } + + @Test + fun `captureAfterChange should return success when repository succeeds`() = runTest { + fakeRepository.captureAfterChangeResult = IntentPayload( + action = "android.intent.action.VIEW", + dataUri = "https://changed.example.com", + categories = listOf("android.intent.category.BROWSABLE"), + flags = 0x14000000, + component = "com.example/.DeepLinkActivity", + extras = emptyMap(), + rawOutput = "changed output", + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.SIMPLE_SPLIT + ) + + val result = useCase.captureAfterChange("emulator-5554") + + assertTrue(result.isSuccess) + val payload = result.getOrNull() + assertNotNull(payload) + assertEquals("https://changed.example.com", payload.dataUri) + assertEquals(ParsingMethod.SIMPLE_SPLIT, payload.parsingMethod) + } + + @Test + fun `captureAfterChange should return failure when repository fails`() = runTest { + fakeRepository.shouldFail = true + fakeRepository.failureException = RuntimeException("Device disconnected") + + val result = useCase.captureAfterChange("emulator-5554") + + assertTrue(result.isFailure) + assertEquals("Device disconnected", result.exceptionOrNull()?.message) + } + + @Test + fun `captureAfterChange should accept custom timeout`() = runTest { + val result = useCase.captureAfterChange("emulator-5554", timeoutMs = 5000) + + assertTrue(result.isSuccess) + } + + @Test + fun `invoke should handle payload with empty extras`() = runTest { + fakeRepository.captureResult = IntentPayload( + action = "android.intent.action.MAIN", + dataUri = null, + categories = emptyList(), + flags = 0, + component = "com.example/.Main", + extras = emptyMap(), + rawOutput = "", + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.RAW_ONLY + ) + + val result = useCase("device-123") + + assertTrue(result.isSuccess) + val payload = result.getOrNull() + assertNotNull(payload) + assertTrue(payload.extras.isEmpty()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/ComparePayloadsUseCaseTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/ComparePayloadsUseCaseTest.kt new file mode 100644 index 0000000..9af31c6 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/ComparePayloadsUseCaseTest.kt @@ -0,0 +1,203 @@ +package com.manjee.linkops.domain.usecase.sniffer + +import com.manjee.linkops.domain.model.BundleExtra +import com.manjee.linkops.domain.model.BundleExtraType +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.model.ParsingMethod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ComparePayloadsUseCaseTest { + + private val useCase = ComparePayloadsUseCase() + + private fun createPayload(extras: Map): IntentPayload { + return IntentPayload( + action = "android.intent.action.VIEW", + dataUri = "https://example.com", + categories = emptyList(), + flags = 0, + component = "com.example/.Main", + extras = extras, + rawOutput = "test", + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.REGEX + ) + } + + private fun stringExtra(key: String, value: String): BundleExtra { + return BundleExtra(key = key, value = value, type = BundleExtraType.STRING) + } + + private fun intExtra(key: String, value: String): BundleExtra { + return BundleExtra(key = key, value = value, type = BundleExtraType.INT) + } + + @Test + fun `invoke should detect added extras`() { + val oldPayload = createPayload( + mapOf("key1" to stringExtra("key1", "value1")) + ) + val newPayload = createPayload( + mapOf( + "key1" to stringExtra("key1", "value1"), + "key2" to stringExtra("key2", "value2") + ) + ) + + val result = useCase(oldPayload, newPayload) + + assertEquals(1, result.addedExtras.size) + assertTrue(result.addedExtras.containsKey("key2")) + assertEquals("value2", result.addedExtras["key2"]?.value) + } + + @Test + fun `invoke should detect removed extras`() { + val oldPayload = createPayload( + mapOf( + "key1" to stringExtra("key1", "value1"), + "key2" to stringExtra("key2", "value2") + ) + ) + val newPayload = createPayload( + mapOf("key1" to stringExtra("key1", "value1")) + ) + + val result = useCase(oldPayload, newPayload) + + assertEquals(1, result.removedExtras.size) + assertTrue(result.removedExtras.containsKey("key2")) + } + + @Test + fun `invoke should detect changed extras`() { + val oldPayload = createPayload( + mapOf("key1" to stringExtra("key1", "old_value")) + ) + val newPayload = createPayload( + mapOf("key1" to stringExtra("key1", "new_value")) + ) + + val result = useCase(oldPayload, newPayload) + + assertEquals(1, result.changedExtras.size) + assertTrue(result.changedExtras.containsKey("key1")) + assertEquals("old_value", result.changedExtras["key1"]?.first?.value) + assertEquals("new_value", result.changedExtras["key1"]?.second?.value) + } + + @Test + fun `invoke should detect unchanged extras`() { + val oldPayload = createPayload( + mapOf("key1" to stringExtra("key1", "same_value")) + ) + val newPayload = createPayload( + mapOf("key1" to stringExtra("key1", "same_value")) + ) + + val result = useCase(oldPayload, newPayload) + + assertEquals(1, result.unchangedExtras.size) + assertTrue(result.unchangedExtras.containsKey("key1")) + assertTrue(result.addedExtras.isEmpty()) + assertTrue(result.removedExtras.isEmpty()) + assertTrue(result.changedExtras.isEmpty()) + } + + @Test + fun `invoke should handle empty extras on both sides`() { + val oldPayload = createPayload(emptyMap()) + val newPayload = createPayload(emptyMap()) + + val result = useCase(oldPayload, newPayload) + + assertTrue(result.addedExtras.isEmpty()) + assertTrue(result.removedExtras.isEmpty()) + assertTrue(result.changedExtras.isEmpty()) + assertTrue(result.unchangedExtras.isEmpty()) + } + + @Test + fun `invoke should handle all additions`() { + val oldPayload = createPayload(emptyMap()) + val newPayload = createPayload( + mapOf( + "key1" to stringExtra("key1", "value1"), + "key2" to intExtra("key2", "42") + ) + ) + + val result = useCase(oldPayload, newPayload) + + assertEquals(2, result.addedExtras.size) + assertTrue(result.removedExtras.isEmpty()) + assertTrue(result.changedExtras.isEmpty()) + assertTrue(result.unchangedExtras.isEmpty()) + } + + @Test + fun `invoke should handle all removals`() { + val oldPayload = createPayload( + mapOf( + "key1" to stringExtra("key1", "value1"), + "key2" to intExtra("key2", "42") + ) + ) + val newPayload = createPayload(emptyMap()) + + val result = useCase(oldPayload, newPayload) + + assertTrue(result.addedExtras.isEmpty()) + assertEquals(2, result.removedExtras.size) + assertTrue(result.changedExtras.isEmpty()) + assertTrue(result.unchangedExtras.isEmpty()) + } + + @Test + fun `invoke should handle complex mixed scenario`() { + val oldPayload = createPayload( + mapOf( + "unchanged" to stringExtra("unchanged", "same"), + "changed" to stringExtra("changed", "old"), + "removed" to stringExtra("removed", "gone") + ) + ) + val newPayload = createPayload( + mapOf( + "unchanged" to stringExtra("unchanged", "same"), + "changed" to stringExtra("changed", "new"), + "added" to stringExtra("added", "fresh") + ) + ) + + val result = useCase(oldPayload, newPayload) + + assertEquals(1, result.addedExtras.size) + assertTrue(result.addedExtras.containsKey("added")) + assertEquals(1, result.removedExtras.size) + assertTrue(result.removedExtras.containsKey("removed")) + assertEquals(1, result.changedExtras.size) + assertTrue(result.changedExtras.containsKey("changed")) + assertEquals(1, result.unchangedExtras.size) + assertTrue(result.unchangedExtras.containsKey("unchanged")) + } + + @Test + fun `invoke should detect type change as changed extra`() { + val oldPayload = createPayload( + mapOf("key1" to stringExtra("key1", "42")) + ) + val newPayload = createPayload( + mapOf("key1" to intExtra("key1", "42")) + ) + + val result = useCase(oldPayload, newPayload) + + // BundleExtra with different type is considered changed + assertEquals(1, result.changedExtras.size) + assertEquals(BundleExtraType.STRING, result.changedExtras["key1"]?.first?.type) + assertEquals(BundleExtraType.INT, result.changedExtras["key1"]?.second?.type) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/FakeIntentPayloadRepository.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/FakeIntentPayloadRepository.kt new file mode 100644 index 0000000..3716897 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/sniffer/FakeIntentPayloadRepository.kt @@ -0,0 +1,43 @@ +package com.manjee.linkops.domain.usecase.sniffer + +import com.manjee.linkops.domain.model.IntentPayload +import com.manjee.linkops.domain.model.ParsingMethod +import com.manjee.linkops.domain.repository.IntentPayloadRepository + +/** + * Fake implementation of IntentPayloadRepository for testing + */ +class FakeIntentPayloadRepository : IntentPayloadRepository { + + var shouldFail = false + var failureException: Exception = RuntimeException("Simulated failure") + var captureResult: IntentPayload = createDefaultPayload() + var captureAfterChangeResult: IntentPayload = createDefaultPayload() + + override suspend fun captureCurrentPayload(deviceSerial: String): Result { + if (shouldFail) return Result.failure(failureException) + return Result.success(captureResult) + } + + override suspend fun captureAfterActivityChange( + deviceSerial: String, + timeoutMs: Long + ): Result { + if (shouldFail) return Result.failure(failureException) + return Result.success(captureAfterChangeResult) + } + + private fun createDefaultPayload(): IntentPayload { + return IntentPayload( + action = "android.intent.action.VIEW", + dataUri = "https://example.com", + categories = emptyList(), + flags = 0, + component = "com.example/.MainActivity", + extras = emptyMap(), + rawOutput = "test output", + timestamp = System.currentTimeMillis(), + parsingMethod = ParsingMethod.REGEX + ) + } +}