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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) }

Expand All @@ -79,6 +83,7 @@ fun App() {
logStreamViewModel.onCleared()
batchTestViewModel.onCleared()
localHostingViewModel.onCleared()
intentSnifferViewModel.onCleared()
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()
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<String, BundleExtra> {
val extras = mutableMapOf<String, BundleExtra>()
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<String, BundleExtra>) {
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
)
}
}
Original file line number Diff line number Diff line change
@@ -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<IntentPayload> {
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<IntentPayload> {
// 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<String>()
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
}
}
Loading