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.Description
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.*
import androidx.compose.runtime.*
Expand All @@ -21,6 +22,8 @@ import com.manjee.linkops.ui.screen.diagnostics.DiagnosticsScreen
import com.manjee.linkops.ui.screen.diagnostics.DiagnosticsViewModel
import com.manjee.linkops.ui.screen.diagnostics.VerificationDeepDiveScreen
import com.manjee.linkops.ui.screen.diagnostics.VerificationDeepDiveViewModel
import com.manjee.linkops.ui.screen.logstream.LogStreamScreen
import com.manjee.linkops.ui.screen.logstream.LogStreamViewModel
import com.manjee.linkops.ui.screen.main.MainScreen
import com.manjee.linkops.ui.screen.main.MainViewModel
import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerScreen
Expand Down Expand Up @@ -52,6 +55,7 @@ fun App() {
val diagnosticsViewModel = remember { DiagnosticsViewModel() }
val manifestAnalyzerViewModel = remember { ManifestAnalyzerViewModel() }
val verificationDeepDiveViewModel = remember { VerificationDeepDiveViewModel() }
val logStreamViewModel = remember { LogStreamViewModel() }
val keyboardShortcutHandler = remember { KeyboardShortcutHandler() }
val searchFocusTrigger = remember { mutableStateOf(0) }

Expand All @@ -64,6 +68,7 @@ fun App() {
diagnosticsViewModel.onCleared()
manifestAnalyzerViewModel.onCleared()
verificationDeepDiveViewModel.onCleared()
logStreamViewModel.onCleared()
}
}

Expand Down Expand Up @@ -141,6 +146,14 @@ fun App() {
)
}

Screen.LogStream -> {
val mainUiState by mainViewModel.uiState.collectAsState()
LogStreamScreen(
viewModel = logStreamViewModel,
devices = mainUiState.devices
)
}

Screen.Settings -> {
// TODO: Implement SettingsScreen
MainScreen(viewModel = mainViewModel)
Expand Down Expand Up @@ -209,6 +222,13 @@ private fun NavigationSidebar(
onClick = { onNavigate(Screen.ManifestAnalyzer) }
)

NavigationRailItem(
icon = { Icon(Icons.Default.Terminal, contentDescription = "Log Streamer") },
label = { Text("Logcat") },
selected = currentScreen == Screen.LogStream,
onClick = { onNavigate(Screen.LogStream) }
)

Spacer(modifier = Modifier.weight(1f))

NavigationRailItem(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.manjee.linkops.data.parser

import com.manjee.linkops.domain.model.DeepLinkEvent
import com.manjee.linkops.domain.model.DeepLinkEventType
import com.manjee.linkops.domain.model.LogEntry
import com.manjee.linkops.domain.model.LogLevel

/**
* Parser for `adb logcat -v time` output lines
*
* Example output format:
* ```
* 01-15 12:34:56.789 I/ActivityTaskManager( 1234): START u0 {act=android.intent.action.VIEW dat=https://example.com/... flg=0x10000000 cmp=com.example.app/.MainActivity}
* 01-15 12:34:56.790 I/IntentResolver( 1234): Resolving intent {act=android.intent.action.VIEW dat=https://example.com/...}
* 01-15 12:34:56.791 W/PackageManager( 1234): Domain verification status: example.com -> verified
* ```
*/
class LogcatParser {

/**
* Parse a single logcat line into a LogEntry
* @param line Raw logcat line
* @return Parsed LogEntry or null if the line cannot be parsed
*/
fun parseLine(line: String): LogEntry? {
if (line.isBlank()) return null

val matchResult = LOGCAT_TIME_PATTERN.matchEntire(line) ?: return parseUnstructuredLine(line)

val timestamp = matchResult.groupValues[1]
val levelChar = matchResult.groupValues[2]
val tag = matchResult.groupValues[3]
val message = matchResult.groupValues[4]

val level = parseLogLevel(levelChar)
val deepLinkEvent = detectDeepLinkEvent(tag, message)

return LogEntry(
timestamp = timestamp,
level = level,
tag = tag,
message = message,
deepLinkEvent = deepLinkEvent
)
}

private fun parseLogLevel(levelChar: String): LogLevel {
return when (levelChar.uppercase()) {
"V" -> LogLevel.VERBOSE
"D" -> LogLevel.DEBUG
"I" -> LogLevel.INFO
"W" -> LogLevel.WARNING
"E" -> LogLevel.ERROR
"F" -> LogLevel.FATAL
else -> LogLevel.UNKNOWN
}
}

private fun parseUnstructuredLine(line: String): LogEntry? {
if (line.startsWith("-----") || line.startsWith("---")) return null

return LogEntry(
timestamp = "",
level = LogLevel.UNKNOWN,
tag = "",
message = line.trim()
)
}

private fun detectDeepLinkEvent(tag: String, message: String): DeepLinkEvent? {
return when {
isIntentStartEvent(tag, message) -> DeepLinkEvent(
type = DeepLinkEventType.STARTED,
description = extractIntentDescription(message)
)
isIntentResolveEvent(tag, message) -> DeepLinkEvent(
type = DeepLinkEventType.RESOLVED,
description = extractResolveDescription(message)
)
isIntentClickEvent(tag, message) -> DeepLinkEvent(
type = DeepLinkEventType.CLICKED,
description = extractClickDescription(message)
)
isIntentResultEvent(tag, message) -> DeepLinkEvent(
type = DeepLinkEventType.RESULT,
description = message.trim()
)
isIntentErrorEvent(tag, message) -> DeepLinkEvent(
type = DeepLinkEventType.ERROR,
description = message.trim()
)
else -> null
}
}

private fun isIntentStartEvent(tag: String, message: String): Boolean {
return tag == "ActivityTaskManager" && message.contains("START")
}

private fun isIntentResolveEvent(tag: String, message: String): Boolean {
return (tag == "IntentResolver" && message.contains("Resolv")) ||
(tag == "PackageManager" && message.contains("verification"))
}

private fun isIntentClickEvent(tag: String, message: String): Boolean {
return (tag == "BrowserActivity" || tag == "ResolverActivity") &&
(message.contains("intent") || message.contains("click"))
}

private fun isIntentResultEvent(tag: String, message: String): Boolean {
return tag == "ActivityTaskManager" &&
(message.contains("Displayed") || message.contains("RESULT"))
}

private fun isIntentErrorEvent(tag: String, message: String): Boolean {
return message.contains("Error", ignoreCase = true) ||
message.contains("Exception", ignoreCase = true) ||
message.contains("not found", ignoreCase = true) ||
message.contains("Permission denied", ignoreCase = true)
}

private fun extractIntentDescription(message: String): String {
val datMatch = DAT_PATTERN.find(message)
val cmpMatch = CMP_PATTERN.find(message)

return buildString {
append("Activity started")
datMatch?.let { append(" - URI: ${it.groupValues[1]}") }
cmpMatch?.let { append(" - Component: ${it.groupValues[1]}") }
}
}

private fun extractResolveDescription(message: String): String {
val datMatch = DAT_PATTERN.find(message)
return if (datMatch != null) {
"Intent resolved for: ${datMatch.groupValues[1]}"
} else {
"Intent resolution: ${message.take(MAX_DESCRIPTION_LENGTH)}"
}
}

private fun extractClickDescription(message: String): String {
return "Deep link clicked: ${message.take(MAX_DESCRIPTION_LENGTH)}"
}

companion object {
private val LOGCAT_TIME_PATTERN = Regex(
"""^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+)\s+([VDIWEF])/(.+?)\s*\(\s*\d+\):\s*(.*)$"""
)
private val DAT_PATTERN = Regex("""dat=(\S+)""")
private val CMP_PATTERN = Regex("""cmp=(\S+)""")
private const val MAX_DESCRIPTION_LENGTH = 120
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.manjee.linkops.data.repository

import com.manjee.linkops.data.parser.LogcatParser
import com.manjee.linkops.domain.model.LogEntry
import com.manjee.linkops.domain.model.LogFilter
import com.manjee.linkops.domain.repository.LogStreamRepository
import com.manjee.linkops.infrastructure.adb.AdbShellExecutor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull

/**
* Implementation of [LogStreamRepository] using ADB logcat streaming
*/
class LogStreamRepositoryImpl(
private val adbExecutor: AdbShellExecutor,
private val logcatParser: LogcatParser
) : LogStreamRepository {

override fun observeLogStream(
deviceSerial: String,
filter: LogFilter
): Flow<LogEntry> {
val command = buildLogcatCommand(filter)
return adbExecutor.executeStreamOnDevice(deviceSerial, command)
.mapNotNull { line -> logcatParser.parseLine(line) }
.mapNotNull { entry -> applyFilter(entry, filter) }
}

private fun buildLogcatCommand(filter: LogFilter): String {
val sb = StringBuilder("logcat -v time")

if (filter.tags.isNotEmpty()) {
sb.append(" -s")
filter.tags.forEach { tag ->
sb.append(" $tag:${levelToChar(filter.minLogLevel)}")
}
}

return sb.toString()
}

private fun applyFilter(entry: LogEntry, filter: LogFilter): LogEntry? {
if (entry.level.ordinal < filter.minLogLevel.ordinal) return null

if (filter.keywords.isNotEmpty()) {
val matchesKeyword = filter.keywords.any { keyword ->
entry.message.contains(keyword, ignoreCase = true) ||
entry.tag.contains(keyword, ignoreCase = true)
}
if (!matchesKeyword) return null
}

return entry
}

private fun levelToChar(level: com.manjee.linkops.domain.model.LogLevel): String {
return level.initial
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ import com.manjee.linkops.data.mapper.DeviceMapper
import com.manjee.linkops.data.parser.AssetLinksParser
import com.manjee.linkops.data.parser.DumpsysParser
import com.manjee.linkops.data.parser.GetAppLinksParser
import com.manjee.linkops.data.parser.LogcatParser
import com.manjee.linkops.data.parser.ManifestParser
import com.manjee.linkops.data.repository.AppLinkRepositoryImpl
import com.manjee.linkops.data.repository.AssetLinksRepositoryImpl
import com.manjee.linkops.data.repository.DeviceRepositoryImpl
import com.manjee.linkops.data.repository.FavoriteRepositoryImpl
import com.manjee.linkops.data.repository.LogStreamRepositoryImpl
import com.manjee.linkops.data.repository.ManifestRepositoryImpl
import com.manjee.linkops.data.repository.VerificationDiagnosticsRepositoryImpl
import com.manjee.linkops.data.strategy.AdbCommandStrategyFactory
import com.manjee.linkops.domain.repository.AppLinkRepository
import com.manjee.linkops.domain.repository.AssetLinksRepository
import com.manjee.linkops.domain.repository.DeviceRepository
import com.manjee.linkops.domain.repository.FavoriteRepository
import com.manjee.linkops.domain.repository.LogStreamRepository
import com.manjee.linkops.domain.repository.ManifestRepository
import com.manjee.linkops.domain.repository.VerificationDiagnosticsRepository
import com.manjee.linkops.domain.usecase.applink.FireIntentUseCase
Expand All @@ -29,6 +32,7 @@ import com.manjee.linkops.domain.usecase.diagnostics.ValidateAssetLinksUseCase
import com.manjee.linkops.domain.usecase.favorite.AddFavoriteUseCase
import com.manjee.linkops.domain.usecase.favorite.ObserveFavoritesUseCase
import com.manjee.linkops.domain.usecase.favorite.RemoveFavoriteUseCase
import com.manjee.linkops.domain.usecase.logstream.ObserveLogStreamUseCase
import com.manjee.linkops.domain.usecase.manifest.AnalyzeManifestUseCase
import com.manjee.linkops.domain.usecase.manifest.GetInstalledPackagesUseCase
import com.manjee.linkops.domain.usecase.manifest.SearchPackagesUseCase
Expand Down Expand Up @@ -78,6 +82,10 @@ object AppContainer {
ManifestParser()
}

private val logcatParser: LogcatParser by lazy {
LogcatParser()
}

// Data - Strategy
private val strategyFactory: AdbCommandStrategyFactory by lazy {
AdbCommandStrategyFactory()
Expand Down Expand Up @@ -130,6 +138,10 @@ object AppContainer {
FavoriteRepositoryImpl()
}

val logStreamRepository: LogStreamRepository by lazy {
LogStreamRepositoryImpl(adbShellExecutor, logcatParser)
}

// UseCases - Device
val detectDevicesUseCase: DetectDevicesUseCase by lazy {
DetectDevicesUseCase(deviceRepository)
Expand Down Expand Up @@ -186,4 +198,9 @@ object AppContainer {
val removeFavoriteUseCase: RemoveFavoriteUseCase by lazy {
RemoveFavoriteUseCase(favoriteRepository)
}

// UseCases - Log Stream
val observeLogStreamUseCase: ObserveLogStreamUseCase by lazy {
ObserveLogStreamUseCase(logStreamRepository)
}
}
Loading