diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt index b930f69..235b892 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.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.* @@ -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 @@ -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) } @@ -64,6 +68,7 @@ fun App() { diagnosticsViewModel.onCleared() manifestAnalyzerViewModel.onCleared() verificationDeepDiveViewModel.onCleared() + logStreamViewModel.onCleared() } } @@ -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) @@ -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( diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/LogcatParser.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/LogcatParser.kt new file mode 100644 index 0000000..f6f91f9 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/LogcatParser.kt @@ -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 + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/LogStreamRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/LogStreamRepositoryImpl.kt new file mode 100644 index 0000000..22891db --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/LogStreamRepositoryImpl.kt @@ -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 { + 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 + } +} 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 7b32820..8ebb3bf 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -6,11 +6,13 @@ 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 @@ -18,6 +20,7 @@ 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 @@ -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 @@ -78,6 +82,10 @@ object AppContainer { ManifestParser() } + private val logcatParser: LogcatParser by lazy { + LogcatParser() + } + // Data - Strategy private val strategyFactory: AdbCommandStrategyFactory by lazy { AdbCommandStrategyFactory() @@ -130,6 +138,10 @@ object AppContainer { FavoriteRepositoryImpl() } + val logStreamRepository: LogStreamRepository by lazy { + LogStreamRepositoryImpl(adbShellExecutor, logcatParser) + } + // UseCases - Device val detectDevicesUseCase: DetectDevicesUseCase by lazy { DetectDevicesUseCase(deviceRepository) @@ -186,4 +198,9 @@ object AppContainer { val removeFavoriteUseCase: RemoveFavoriteUseCase by lazy { RemoveFavoriteUseCase(favoriteRepository) } + + // UseCases - Log Stream + val observeLogStreamUseCase: ObserveLogStreamUseCase by lazy { + ObserveLogStreamUseCase(logStreamRepository) + } } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/LogStream.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/LogStream.kt new file mode 100644 index 0000000..8a35b0d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/LogStream.kt @@ -0,0 +1,76 @@ +package com.manjee.linkops.domain.model + +/** + * Represents a single parsed logcat entry + */ +data class LogEntry( + val timestamp: String, + val level: LogLevel, + val tag: String, + val message: String, + val deepLinkEvent: DeepLinkEvent? = null +) { + val isDeepLinkEvent: Boolean get() = deepLinkEvent != null +} + +/** + * Log level from logcat output + */ +enum class LogLevel { + VERBOSE, + DEBUG, + INFO, + WARNING, + ERROR, + FATAL, + UNKNOWN; + + val initial: String + get() = when (this) { + VERBOSE -> "V" + DEBUG -> "D" + INFO -> "I" + WARNING -> "W" + ERROR -> "E" + FATAL -> "F" + UNKNOWN -> "?" + } +} + +/** + * Filter configuration for logcat streaming + */ +data class LogFilter( + val tags: Set = DEFAULT_TAGS, + val keywords: Set = emptySet(), + val minLogLevel: LogLevel = LogLevel.VERBOSE +) { + companion object { + val DEFAULT_TAGS = setOf( + "ActivityTaskManager", + "IntentResolver", + "PackageManager", + "BrowserActivity", + "ResolverActivity" + ) + } +} + +/** + * Type of deep link lifecycle event + */ +enum class DeepLinkEventType { + CLICKED, + RESOLVED, + STARTED, + RESULT, + ERROR +} + +/** + * Represents a deep link lifecycle event extracted from a log entry + */ +data class DeepLinkEvent( + val type: DeepLinkEventType, + val description: String +) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/LogStreamRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/LogStreamRepository.kt new file mode 100644 index 0000000..d5767e8 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/LogStreamRepository.kt @@ -0,0 +1,21 @@ +package com.manjee.linkops.domain.repository + +import com.manjee.linkops.domain.model.LogEntry +import com.manjee.linkops.domain.model.LogFilter +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for logcat streaming operations + */ +interface LogStreamRepository { + /** + * Observes logcat output in real-time with the given filter + * @param deviceSerial Device serial number + * @param filter Log filter configuration + * @return Flow of parsed log entries + */ + fun observeLogStream( + deviceSerial: String, + filter: LogFilter + ): Flow +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/logstream/ObserveLogStreamUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/logstream/ObserveLogStreamUseCase.kt new file mode 100644 index 0000000..a7af344 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/logstream/ObserveLogStreamUseCase.kt @@ -0,0 +1,27 @@ +package com.manjee.linkops.domain.usecase.logstream + +import com.manjee.linkops.domain.model.LogEntry +import com.manjee.linkops.domain.model.LogFilter +import com.manjee.linkops.domain.repository.LogStreamRepository +import kotlinx.coroutines.flow.Flow + +/** + * Observes real-time logcat stream filtered for deep link events + * + * @param logStreamRepository Repository for log stream operations + */ +class ObserveLogStreamUseCase( + private val logStreamRepository: LogStreamRepository +) { + /** + * @param deviceSerial Device serial number + * @param filter Log filter configuration + * @return Flow of parsed log entries + */ + operator fun invoke( + deviceSerial: String, + filter: LogFilter + ): Flow { + return logStreamRepository.observeLogStream(deviceSerial, filter) + } +} 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 ef982e5..dfba84f 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 @@ -120,4 +120,5 @@ fun NavigationController.navigateToDashboard() = navigateTo(Screen.Dashboard) fun NavigationController.navigateToDeviceSelection() = navigateTo(Screen.DeviceSelection) fun NavigationController.navigateToDiagnostics() = navigateTo(Screen.Diagnostics) fun NavigationController.navigateToVerificationDeepDive() = navigateTo(Screen.VerificationDeepDive) +fun NavigationController.navigateToLogStream() = navigateTo(Screen.LogStream) 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 0527bdf..31e879d 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 @@ -36,6 +36,11 @@ sealed class Screen(val route: String, val title: String) { */ data object VerificationDeepDive : Screen("verification_deep_dive", "Verification Deep Dive") + /** + * Log streamer screen - for real-time logcat streaming with deep link filtering + */ + data object LogStream : Screen("log_stream", "Log Streamer") + /** * Settings screen */ @@ -48,7 +53,9 @@ sealed class Screen(val route: String, val title: String) { fun mainScreens(): List = listOf( Dashboard, Diagnostics, + VerificationDeepDive, ManifestAnalyzer, + LogStream, Settings ) } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/logstream/LogStreamScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/logstream/LogStreamScreen.kt new file mode 100644 index 0000000..2d26b64 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/logstream/LogStreamScreen.kt @@ -0,0 +1,808 @@ +package com.manjee.linkops.ui.screen.logstream + +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.lazy.rememberLazyListState +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.* +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.unit.dp +import androidx.compose.ui.unit.sp +import com.manjee.linkops.domain.model.Device +import com.manjee.linkops.domain.model.LogLevel +import com.manjee.linkops.ui.component.EmptyState +import com.manjee.linkops.ui.theme.LinkOpsColors +import com.manjee.linkops.domain.model.DeepLinkEventType +import com.manjee.linkops.domain.model.LogEntry as DomainLogEntry + +/** + * Log Stream Screen - Real-time logcat streaming with deep link filtering + * + * Layout: + * - Left panel: Controls (device selector, filters, streaming controls) + * - Right panel: Log output with color-coded entries + */ +@Composable +fun LogStreamScreen( + viewModel: LogStreamViewModel, + 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 + ControlPanel( + uiState = uiState, + devices = devices, + selectedDevice = selectedDevice, + onDeviceSelected = { selectedDevice = it }, + onStartStreaming = { device -> viewModel.startStreaming(device) }, + onStopStreaming = { viewModel.stopStreaming() }, + onPauseStreaming = { viewModel.pauseStreaming() }, + onResumeStreaming = { viewModel.resumeStreaming() }, + onClearEntries = { viewModel.clearEntries() }, + onSearchQueryChanged = { viewModel.updateSearchQuery(it) }, + onLogLevelChanged = { viewModel.updateLogLevel(it) }, + onToggleDeepLinkEvents = { viewModel.toggleDeepLinkEventsOnly() }, + onCustomTagInputChanged = { viewModel.updateCustomTagInput(it) }, + onAddCustomTag = { viewModel.addCustomTag() }, + onRemoveTag = { viewModel.removeTag(it) }, + onCustomKeywordInputChanged = { viewModel.updateCustomKeywordInput(it) }, + onAddCustomKeyword = { viewModel.addCustomKeyword() }, + onRemoveKeyword = { viewModel.removeKeyword(it) }, + onExport = { viewModel.exportLogs() }, + modifier = Modifier + .width(340.dp) + .fillMaxHeight() + ) + + // Right Panel - Log Output + LogStreamPanel( + entries = uiState.entries, + streamingState = uiState.streamingState, + onClear = { viewModel.clearEntries() }, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + } +} + +/** + * Left panel with streaming controls and filters + */ +@Composable +private fun ControlPanel( + uiState: LogStreamUiState, + devices: List, + selectedDevice: Device?, + onDeviceSelected: (Device) -> Unit, + onStartStreaming: (Device) -> Unit, + onStopStreaming: () -> Unit, + onPauseStreaming: () -> Unit, + onResumeStreaming: () -> Unit, + onClearEntries: () -> Unit, + onSearchQueryChanged: (String) -> Unit, + onLogLevelChanged: (LogLevel) -> Unit, + onToggleDeepLinkEvents: () -> Unit, + onCustomTagInputChanged: (String) -> Unit, + onAddCustomTag: () -> Unit, + onRemoveTag: (String) -> Unit, + onCustomKeywordInputChanged: (String) -> Unit, + onAddCustomKeyword: () -> Unit, + onRemoveKeyword: (String) -> Unit, + onExport: () -> String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header + Text( + text = "Log Streamer", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + HorizontalDivider() + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Device Selection + item { + DeviceSelectionSection( + devices = devices, + selectedDevice = selectedDevice, + onDeviceSelected = onDeviceSelected + ) + } + + item { HorizontalDivider() } + + // Streaming Controls + item { + StreamingControlsSection( + streamingState = uiState.streamingState, + selectedDevice = selectedDevice, + entryCount = uiState.entries.size, + onStart = { selectedDevice?.let { onStartStreaming(it) } }, + onStop = onStopStreaming, + onPause = onPauseStreaming, + onResume = onResumeStreaming, + onClear = onClearEntries, + onExport = onExport + ) + } + + item { HorizontalDivider() } + + // Search & Filter + item { + SearchFilterSection( + searchQuery = uiState.searchQuery, + selectedLevel = uiState.selectedLevel, + showOnlyDeepLinkEvents = uiState.showOnlyDeepLinkEvents, + onSearchQueryChanged = onSearchQueryChanged, + onLogLevelChanged = onLogLevelChanged, + onToggleDeepLinkEvents = onToggleDeepLinkEvents + ) + } + + item { HorizontalDivider() } + + // Tag & Keyword Filters + item { + TagKeywordSection( + tags = uiState.filter.tags, + keywords = uiState.filter.keywords, + customTagInput = uiState.customTagInput, + customKeywordInput = uiState.customKeywordInput, + onCustomTagInputChanged = onCustomTagInputChanged, + onAddCustomTag = onAddCustomTag, + onRemoveTag = onRemoveTag, + onCustomKeywordInputChanged = onCustomKeywordInputChanged, + onAddCustomKeyword = onAddCustomKeyword, + onRemoveKeyword = onRemoveKeyword + ) + } + } + + // Error display + uiState.error?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Error + ) + } + } +} + +/** + * Device selection section + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeviceSelectionSection( + devices: List, + selectedDevice: Device?, + onDeviceSelected: (Device) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Device", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + if (devices.isEmpty()) { + Text( + text = "No devices connected. Refresh from Dashboard.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = selectedDevice?.displayName ?: "Select a device", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + singleLine = true + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + devices.filter { it.isAvailable }.forEach { device -> + DropdownMenuItem( + text = { Text(device.displayName) }, + onClick = { + onDeviceSelected(device) + expanded = false + } + ) + } + } + } + } + } +} + +/** + * Streaming control buttons + */ +@Composable +private fun StreamingControlsSection( + streamingState: StreamingState, + selectedDevice: Device?, + entryCount: Int, + onStart: () -> Unit, + onStop: () -> Unit, + onPause: () -> Unit, + onResume: () -> Unit, + onClear: () -> Unit, + onExport: () -> String +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Controls", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Entry count badge + Text( + text = "$entryCount entries", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Streaming state indicator + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val (statusText, statusColor) = when (streamingState) { + StreamingState.IDLE -> "Idle" to LinkOpsColors.Unknown + StreamingState.STREAMING -> "Streaming" to LinkOpsColors.Success + StreamingState.PAUSED -> "Paused" to LinkOpsColors.Warning + } + + Box( + modifier = Modifier + .size(8.dp) + .background(statusColor, RoundedCornerShape(50)) + ) + Text( + text = statusText, + style = MaterialTheme.typography.bodySmall, + color = statusColor + ) + } + + // Control buttons + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + when (streamingState) { + StreamingState.IDLE -> { + Button( + onClick = onStart, + enabled = selectedDevice != null, + colors = ButtonDefaults.buttonColors( + containerColor = LinkOpsColors.Success + ) + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Start") + } + } + StreamingState.STREAMING -> { + Button( + onClick = onPause, + colors = ButtonDefaults.buttonColors( + containerColor = LinkOpsColors.Warning + ) + ) { + Icon(Icons.Default.Pause, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Pause") + } + Button( + onClick = onStop, + colors = ButtonDefaults.buttonColors( + containerColor = LinkOpsColors.Error + ) + ) { + Icon(Icons.Default.Stop, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Stop") + } + } + StreamingState.PAUSED -> { + Button( + onClick = onResume, + colors = ButtonDefaults.buttonColors( + containerColor = LinkOpsColors.Success + ) + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Resume") + } + Button( + onClick = onStop, + colors = ButtonDefaults.buttonColors( + containerColor = LinkOpsColors.Error + ) + ) { + Icon(Icons.Default.Stop, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Stop") + } + } + } + } + + // Clear & Export + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton(onClick = onClear) { + Icon(Icons.Default.Clear, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Clear") + } + + OutlinedButton( + onClick = { onExport() }, + enabled = entryCount > 0 + ) { + Icon(Icons.Default.SaveAlt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Export") + } + } + } +} + +/** + * Search and filter controls + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchFilterSection( + searchQuery: String, + selectedLevel: LogLevel, + showOnlyDeepLinkEvents: Boolean, + onSearchQueryChanged: (String) -> Unit, + onLogLevelChanged: (LogLevel) -> Unit, + onToggleDeepLinkEvents: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Filters", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Search + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChanged, + label = { Text("Search logs") }, + placeholder = { Text("Filter by keyword...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChanged("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + } + ) + + // Log level dropdown + var levelExpanded by remember { mutableStateOf(false) } + val filterLevels = listOf( + LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARNING, LogLevel.ERROR + ) + + ExposedDropdownMenuBox( + expanded = levelExpanded, + onExpandedChange = { levelExpanded = !levelExpanded } + ) { + OutlinedTextField( + value = "Min Level: ${selectedLevel.name}", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = levelExpanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + singleLine = true + ) + + ExposedDropdownMenu( + expanded = levelExpanded, + onDismissRequest = { levelExpanded = false } + ) { + filterLevels.forEach { level -> + DropdownMenuItem( + text = { Text(level.name) }, + onClick = { + onLogLevelChanged(level) + levelExpanded = false + } + ) + } + } + } + + // Deep link events only toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Deep link events only", + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = showOnlyDeepLinkEvents, + onCheckedChange = { onToggleDeepLinkEvents() } + ) + } + } +} + +/** + * Tag and keyword filter management + */ +@Composable +private fun TagKeywordSection( + tags: Set, + keywords: Set, + customTagInput: String, + customKeywordInput: String, + onCustomTagInputChanged: (String) -> Unit, + onAddCustomTag: () -> Unit, + onRemoveTag: (String) -> Unit, + onCustomKeywordInputChanged: (String) -> Unit, + onAddCustomKeyword: () -> Unit, + onRemoveKeyword: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Tags", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Current tags as chips + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + tags.forEach { tag -> + InputChip( + selected = true, + onClick = { onRemoveTag(tag) }, + label = { Text(tag, style = MaterialTheme.typography.labelSmall) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = "Remove $tag", + modifier = Modifier.size(14.dp) + ) + } + ) + } + } + + // Add custom tag + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + OutlinedTextField( + value = customTagInput, + onValueChange = onCustomTagInputChanged, + placeholder = { Text("Add tag...") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + IconButton(onClick = onAddCustomTag) { + Icon(Icons.Default.Add, contentDescription = "Add tag") + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Keywords", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + // Current keywords as chips + if (keywords.isNotEmpty()) { + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + keywords.forEach { keyword -> + InputChip( + selected = true, + onClick = { onRemoveKeyword(keyword) }, + label = { Text(keyword, style = MaterialTheme.typography.labelSmall) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = "Remove $keyword", + modifier = Modifier.size(14.dp) + ) + } + ) + } + } + } + + // Add custom keyword + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + OutlinedTextField( + value = customKeywordInput, + onValueChange = onCustomKeywordInputChanged, + placeholder = { Text("Add keyword...") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + IconButton(onClick = onAddCustomKeyword) { + Icon(Icons.Default.Add, contentDescription = "Add keyword") + } + } + } +} + +/** + * Right panel - Log stream output + */ +@Composable +private fun LogStreamPanel( + entries: List, + streamingState: StreamingState, + onClear: () -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + + // Auto-scroll to bottom when new entries arrive + LaunchedEffect(entries.size) { + if (entries.isNotEmpty()) { + listState.animateScrollToItem(entries.size - 1) + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Log Output", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + if (streamingState == StreamingState.STREAMING) { + Text( + text = "LIVE", + style = MaterialTheme.typography.labelSmall, + color = LinkOpsColors.OnPrimary, + modifier = Modifier + .background(LinkOpsColors.Error, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + IconButton( + onClick = onClear, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear log", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Log content + Box( + modifier = Modifier + .fillMaxSize() + .background( + LinkOpsColors.TerminalBackground, + RoundedCornerShape(8.dp) + ) + .padding(12.dp) + ) { + if (entries.isEmpty()) { + EmptyState( + title = "No log entries", + description = "Start streaming to capture logcat output", + icon = Icons.Default.Terminal + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items(entries) { entry -> + LogEntryRow(entry) + } + } + } + } + } +} + +/** + * Single log entry row with color-coded level + */ +@Composable +private fun LogEntryRow(entry: DomainLogEntry) { + val levelColor = when (entry.level) { + LogLevel.VERBOSE -> LinkOpsColors.TerminalText.copy(alpha = 0.5f) + LogLevel.DEBUG -> LinkOpsColors.TerminalText.copy(alpha = 0.7f) + LogLevel.INFO -> LinkOpsColors.TerminalInfo + LogLevel.WARNING -> LinkOpsColors.TerminalWarning + LogLevel.ERROR -> LinkOpsColors.TerminalError + LogLevel.FATAL -> LinkOpsColors.Error + LogLevel.UNKNOWN -> LinkOpsColors.TerminalText.copy(alpha = 0.4f) + } + + val eventColor = when (entry.deepLinkEvent?.type) { + DeepLinkEventType.CLICKED -> LinkOpsColors.Info + DeepLinkEventType.RESOLVED -> LinkOpsColors.Primary + DeepLinkEventType.STARTED -> LinkOpsColors.Success + DeepLinkEventType.RESULT -> LinkOpsColors.Success + DeepLinkEventType.ERROR -> LinkOpsColors.Error + null -> null + } + + Column(modifier = Modifier.padding(vertical = 1.dp)) { + Row { + // Timestamp + if (entry.timestamp.isNotEmpty()) { + Text( + text = entry.timestamp, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + color = LinkOpsColors.TerminalText.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + // Level + Text( + text = entry.level.initial, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + color = levelColor + ) + Spacer(modifier = Modifier.width(4.dp)) + + // Tag + if (entry.tag.isNotEmpty()) { + Text( + text = entry.tag, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ), + color = LinkOpsColors.TerminalInfo.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + // Message + SelectionContainer { + Text( + text = entry.message, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + color = LinkOpsColors.TerminalText + ) + } + } + + // Deep link event indicator + if (entry.deepLinkEvent != null && eventColor != null) { + Row( + modifier = Modifier.padding(start = 16.dp, top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(eventColor, RoundedCornerShape(50)) + ) + Text( + text = "${entry.deepLinkEvent.type.name}: ${entry.deepLinkEvent.description}", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ), + color = eventColor + ) + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/logstream/LogStreamViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/logstream/LogStreamViewModel.kt new file mode 100644 index 0000000..93dfc0e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/logstream/LogStreamViewModel.kt @@ -0,0 +1,259 @@ +package com.manjee.linkops.ui.screen.logstream + +import com.manjee.linkops.di.AppContainer +import com.manjee.linkops.domain.model.Device +import com.manjee.linkops.domain.model.LogEntry +import com.manjee.linkops.domain.model.LogFilter +import com.manjee.linkops.domain.model.LogLevel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +/** + * Streaming state for the log streamer + */ +enum class StreamingState { + IDLE, + STREAMING, + PAUSED +} + +/** + * UI State for Log Stream Screen + */ +data class LogStreamUiState( + val streamingState: StreamingState = StreamingState.IDLE, + val entries: List = emptyList(), + val filter: LogFilter = LogFilter(), + val searchQuery: String = "", + val selectedLevel: LogLevel = LogLevel.VERBOSE, + val customTagInput: String = "", + val customKeywordInput: String = "", + val showOnlyDeepLinkEvents: Boolean = false, + val error: String? = null +) + +/** + * ViewModel for Log Stream Screen + * + * Manages real-time logcat streaming, filtering, and log entry display + */ +class LogStreamViewModel { + private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _uiState = MutableStateFlow(LogStreamUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var streamJob: Job? = null + private val allEntries = mutableListOf() + + /** + * Start streaming logcat from the given device + */ + fun startStreaming(device: Device) { + if (_uiState.value.streamingState == StreamingState.STREAMING) return + + streamJob?.cancel() + _uiState.update { it.copy(streamingState = StreamingState.STREAMING, error = null) } + + streamJob = viewModelScope.launch { + try { + AppContainer.observeLogStreamUseCase( + deviceSerial = device.serialNumber, + filter = _uiState.value.filter + ).collect { entry -> + if (_uiState.value.streamingState == StreamingState.STREAMING) { + addEntry(entry) + } + } + } catch (e: CancellationException) { + // Normal cancellation, do nothing + } catch (e: Exception) { + _uiState.update { + it.copy( + streamingState = StreamingState.IDLE, + error = e.message ?: "Stream failed" + ) + } + } + } + } + + /** + * Stop streaming + */ + fun stopStreaming() { + streamJob?.cancel() + streamJob = null + _uiState.update { it.copy(streamingState = StreamingState.IDLE) } + } + + /** + * Pause streaming (keeps connection but stops adding entries) + */ + fun pauseStreaming() { + if (_uiState.value.streamingState == StreamingState.STREAMING) { + _uiState.update { it.copy(streamingState = StreamingState.PAUSED) } + } + } + + /** + * Resume streaming after pause + */ + fun resumeStreaming() { + if (_uiState.value.streamingState == StreamingState.PAUSED) { + _uiState.update { it.copy(streamingState = StreamingState.STREAMING) } + } + } + + /** + * Clear all log entries + */ + fun clearEntries() { + allEntries.clear() + _uiState.update { it.copy(entries = emptyList()) } + } + + /** + * Update search query for filtering displayed entries + */ + fun updateSearchQuery(query: String) { + _uiState.update { it.copy(searchQuery = query) } + refreshFilteredEntries() + } + + /** + * Update minimum log level filter + */ + fun updateLogLevel(level: LogLevel) { + _uiState.update { + it.copy( + selectedLevel = level, + filter = it.filter.copy(minLogLevel = level) + ) + } + refreshFilteredEntries() + } + + /** + * Toggle deep link events only filter + */ + fun toggleDeepLinkEventsOnly() { + _uiState.update { it.copy(showOnlyDeepLinkEvents = !it.showOnlyDeepLinkEvents) } + refreshFilteredEntries() + } + + /** + * Update custom tag input field + */ + fun updateCustomTagInput(input: String) { + _uiState.update { it.copy(customTagInput = input) } + } + + /** + * Add a custom tag to the filter + */ + fun addCustomTag() { + val tag = _uiState.value.customTagInput.trim() + if (tag.isBlank()) return + + _uiState.update { + it.copy( + filter = it.filter.copy(tags = it.filter.tags + tag), + customTagInput = "" + ) + } + } + + /** + * Remove a tag from the filter + */ + fun removeTag(tag: String) { + _uiState.update { + it.copy(filter = it.filter.copy(tags = it.filter.tags - tag)) + } + } + + /** + * Update custom keyword input field + */ + fun updateCustomKeywordInput(input: String) { + _uiState.update { it.copy(customKeywordInput = input) } + } + + /** + * Add a custom keyword to the filter + */ + fun addCustomKeyword() { + val keyword = _uiState.value.customKeywordInput.trim() + if (keyword.isBlank()) return + + _uiState.update { + it.copy( + filter = it.filter.copy(keywords = it.filter.keywords + keyword), + customKeywordInput = "" + ) + } + refreshFilteredEntries() + } + + /** + * Remove a keyword from the filter + */ + fun removeKeyword(keyword: String) { + _uiState.update { + it.copy(filter = it.filter.copy(keywords = it.filter.keywords - keyword)) + } + refreshFilteredEntries() + } + + /** + * Generate exportable text from current log entries + * @return Formatted log text + */ + fun exportLogs(): String { + return _uiState.value.entries.joinToString("\n") { entry -> + "${entry.timestamp} ${entry.level.initial}/${entry.tag}: ${entry.message}" + } + } + + /** + * Clear error state + */ + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + /** + * Cleanup resources + */ + fun onCleared() { + viewModelScope.cancel() + } + + private fun addEntry(entry: LogEntry) { + // Ring buffer: keep max entries to prevent memory issues + if (allEntries.size >= MAX_ENTRIES) { + allEntries.removeAt(0) + } + allEntries.add(entry) + refreshFilteredEntries() + } + + private fun refreshFilteredEntries() { + val state = _uiState.value + val filtered = allEntries.filter { entry -> + val matchesSearch = state.searchQuery.isBlank() || + entry.message.contains(state.searchQuery, ignoreCase = true) || + entry.tag.contains(state.searchQuery, ignoreCase = true) + + val matchesDeepLinkFilter = !state.showOnlyDeepLinkEvents || entry.isDeepLinkEvent + + matchesSearch && matchesDeepLinkFilter + } + _uiState.update { it.copy(entries = filtered) } + } + + companion object { + private const val MAX_ENTRIES = 5000 + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/LogcatParserTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/LogcatParserTest.kt new file mode 100644 index 0000000..acf01a0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/LogcatParserTest.kt @@ -0,0 +1,277 @@ +package com.manjee.linkops.data.parser + +import com.manjee.linkops.domain.model.DeepLinkEventType +import com.manjee.linkops.domain.model.LogLevel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LogcatParserTest { + + private val parser = LogcatParser() + + // --- Happy Path Tests --- + + @Test + fun `parseLine should parse standard logcat time format correctly`() { + val line = "01-15 12:34:56.789 I/ActivityTaskManager( 1234): START u0 {act=android.intent.action.VIEW dat=https://example.com cmp=com.example.app/.MainActivity}" + + val entry = parser.parseLine(line) + + assertNotNull(entry, "Entry should not be null") + assertEquals("01-15 12:34:56.789", entry.timestamp) + assertEquals(LogLevel.INFO, entry.level) + assertEquals("ActivityTaskManager", entry.tag) + assertTrue(entry.message.contains("START"), "Message should contain START") + } + + @Test + fun `parseLine should parse all log levels correctly`() { + val levels = mapOf( + "V" to LogLevel.VERBOSE, + "D" to LogLevel.DEBUG, + "I" to LogLevel.INFO, + "W" to LogLevel.WARNING, + "E" to LogLevel.ERROR, + "F" to LogLevel.FATAL + ) + + levels.forEach { (char, expectedLevel) -> + val line = "01-15 12:34:56.789 $char/TestTag( 1234): Test message" + val entry = parser.parseLine(line) + + assertNotNull(entry, "Entry should not be null for level $char") + assertEquals(expectedLevel, entry.level, "Level should match for $char") + } + } + + @Test + fun `parseLine should extract tag correctly from various formats`() { + val line = "01-15 12:34:56.789 I/IntentResolver( 5678): Resolving intent" + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertEquals("IntentResolver", entry.tag) + } + + @Test + fun `parseLine should extract message content correctly`() { + val message = "Some detailed log message with special chars: [test] {data}" + val line = "01-15 12:34:56.789 D/TestTag( 1234): $message" + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertEquals(message, entry.message) + } + + // --- Deep Link Event Detection Tests --- + + @Test + fun `parseLine should detect START as deep link STARTED event`() { + val line = "01-15 12:34:56.789 I/ActivityTaskManager( 1234): START u0 {act=android.intent.action.VIEW dat=https://example.com/path cmp=com.example/.Main}" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertNotNull(entry.deepLinkEvent, "Should detect deep link event") + assertEquals(DeepLinkEventType.STARTED, entry.deepLinkEvent!!.type) + assertTrue(entry.isDeepLinkEvent) + } + + @Test + fun `parseLine should detect IntentResolver as RESOLVED event`() { + val line = "01-15 12:34:56.789 I/IntentResolver( 1234): Resolving intent {act=android.intent.action.VIEW dat=https://example.com}" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertNotNull(entry.deepLinkEvent) + assertEquals(DeepLinkEventType.RESOLVED, entry.deepLinkEvent!!.type) + } + + @Test + fun `parseLine should detect PackageManager verification as RESOLVED event`() { + val line = "01-15 12:34:56.789 I/PackageManager( 1234): Domain verification status: example.com -> verified" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertNotNull(entry.deepLinkEvent) + assertEquals(DeepLinkEventType.RESOLVED, entry.deepLinkEvent!!.type) + } + + @Test + fun `parseLine should detect Displayed as RESULT event`() { + val line = "01-15 12:34:56.789 I/ActivityTaskManager( 1234): Displayed com.example.app/.MainActivity: +345ms" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertNotNull(entry.deepLinkEvent) + assertEquals(DeepLinkEventType.RESULT, entry.deepLinkEvent!!.type) + } + + @Test + fun `parseLine should detect error messages as ERROR event`() { + val line = "01-15 12:34:56.789 E/ActivityTaskManager( 1234): Error: activity not found for intent" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertNotNull(entry.deepLinkEvent) + assertEquals(DeepLinkEventType.ERROR, entry.deepLinkEvent!!.type) + } + + @Test + fun `parseLine should return null deepLinkEvent for non-deeplink entries`() { + val line = "01-15 12:34:56.789 D/SomeTag( 1234): Regular log message" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertNull(entry.deepLinkEvent) + assertTrue(!entry.isDeepLinkEvent) + } + + // --- Edge Cases --- + + @Test + fun `parseLine should return null for empty string`() { + val entry = parser.parseLine("") + assertNull(entry, "Empty string should return null") + } + + @Test + fun `parseLine should return null for blank string`() { + val entry = parser.parseLine(" ") + assertNull(entry, "Blank string should return null") + } + + @Test + fun `parseLine should return null for logcat separator lines`() { + assertNull(parser.parseLine("--------- beginning of main")) + assertNull(parser.parseLine("--------- beginning of system")) + assertNull(parser.parseLine("--- separator ---")) + } + + @Test + fun `parseLine should handle ADB error messages gracefully`() { + val entry = parser.parseLine("error: device not found") + + assertNotNull(entry, "Should handle error message as unstructured line") + assertEquals(LogLevel.UNKNOWN, entry.level) + assertEquals("", entry.tag) + assertTrue(entry.message.contains("error: device not found")) + } + + @Test + fun `parseLine should handle permission denied messages`() { + val entry = parser.parseLine("Permission denied") + + assertNotNull(entry) + assertEquals(LogLevel.UNKNOWN, entry.level) + } + + @Test + fun `parseLine should handle truncated output`() { + val entry = parser.parseLine("01-15 12:34") + + // Should attempt to parse, might return unstructured entry + if (entry != null) { + assertEquals(LogLevel.UNKNOWN, entry.level) + } + } + + @Test + fun `parseLine should handle special characters in messages`() { + val line = "01-15 12:34:56.789 I/TestTag( 1234): Message with \$pecial ch@rs & \"quotes\"" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertTrue(entry.message.contains("\$pecial"), "Should preserve special characters") + } + + @Test + fun `parseLine should handle large PID numbers`() { + val line = "01-15 12:34:56.789 I/TestTag(99999): Test message" + + val entry = parser.parseLine(line) + + assertNotNull(entry) + assertEquals("TestTag", entry.tag) + } + + @Test + fun `parseLine should handle unknown log level character`() { + val line = "01-15 12:34:56.789 X/TestTag( 1234): Test message" + + val entry = parser.parseLine(line) + + // X is not a recognized level - should either be UNKNOWN or fail to parse + if (entry != null && entry.tag == "TestTag") { + assertEquals(LogLevel.UNKNOWN, entry.level) + } + } + + // --- Multiple Entry Parsing Tests --- + + @Test + fun `parseLine should handle consecutive entries independently`() { + val lines = listOf( + "01-15 12:34:56.789 I/ActivityTaskManager( 1234): START u0 {dat=https://test.com}", + "01-15 12:34:56.790 D/IntentResolver( 1234): Resolving intent {dat=https://test.com}", + "01-15 12:34:56.791 I/ActivityTaskManager( 1234): Displayed com.example/.Main: +100ms" + ) + + val entries = lines.mapNotNull { parser.parseLine(it) } + + assertEquals(3, entries.size) + assertEquals(DeepLinkEventType.STARTED, entries[0].deepLinkEvent?.type) + assertEquals(DeepLinkEventType.RESOLVED, entries[1].deepLinkEvent?.type) + assertEquals(DeepLinkEventType.RESULT, entries[2].deepLinkEvent?.type) + } + + // --- LogLevel Enum Tests --- + + @Test + fun `LogLevel initial should return correct character`() { + assertEquals("V", LogLevel.VERBOSE.initial) + assertEquals("D", LogLevel.DEBUG.initial) + assertEquals("I", LogLevel.INFO.initial) + assertEquals("W", LogLevel.WARNING.initial) + assertEquals("E", LogLevel.ERROR.initial) + assertEquals("F", LogLevel.FATAL.initial) + assertEquals("?", LogLevel.UNKNOWN.initial) + } + + // --- Intent Description Extraction --- + + @Test + fun `parseLine should extract URI from dat= in START events`() { + val line = "01-15 12:34:56.789 I/ActivityTaskManager( 1234): START u0 {act=android.intent.action.VIEW dat=https://example.com/path?q=test cmp=com.example/.Main}" + + val entry = parser.parseLine(line) + + assertNotNull(entry?.deepLinkEvent) + assertTrue( + entry!!.deepLinkEvent!!.description.contains("https://example.com/path?q=test"), + "Description should contain the URI" + ) + } + + @Test + fun `parseLine should extract component from cmp= in START events`() { + val line = "01-15 12:34:56.789 I/ActivityTaskManager( 1234): START u0 {dat=https://test.com cmp=com.example.app/.MainActivity}" + + val entry = parser.parseLine(line) + + assertNotNull(entry?.deepLinkEvent) + assertTrue( + entry!!.deepLinkEvent!!.description.contains("com.example.app/.MainActivity"), + "Description should contain the component" + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/logstream/ObserveLogStreamUseCaseTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/logstream/ObserveLogStreamUseCaseTest.kt new file mode 100644 index 0000000..99681b1 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/logstream/ObserveLogStreamUseCaseTest.kt @@ -0,0 +1,113 @@ +package com.manjee.linkops.domain.usecase.logstream + +import com.manjee.linkops.domain.model.LogEntry +import com.manjee.linkops.domain.model.LogFilter +import com.manjee.linkops.domain.model.LogLevel +import com.manjee.linkops.domain.repository.LogStreamRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ObserveLogStreamUseCaseTest { + + @Test + fun `invoke should return log entries from repository`() = runTest { + val expectedEntries = listOf( + LogEntry( + timestamp = "01-15 12:34:56.789", + level = LogLevel.INFO, + tag = "ActivityTaskManager", + message = "START u0 {dat=https://example.com}" + ), + LogEntry( + timestamp = "01-15 12:34:56.790", + level = LogLevel.DEBUG, + tag = "IntentResolver", + message = "Resolving intent" + ) + ) + + val fakeRepository = FakeLogStreamRepository(expectedEntries) + val useCase = ObserveLogStreamUseCase(fakeRepository) + + val result = useCase("device-123", LogFilter()).toList() + + assertEquals(expectedEntries.size, result.size) + assertEquals(expectedEntries[0].tag, result[0].tag) + assertEquals(expectedEntries[1].tag, result[1].tag) + } + + @Test + fun `invoke should return empty flow when no entries match`() = runTest { + val fakeRepository = FakeLogStreamRepository(emptyList()) + val useCase = ObserveLogStreamUseCase(fakeRepository) + + val result = useCase("device-123", LogFilter()).toList() + + assertTrue(result.isEmpty(), "Should return empty list") + } + + @Test + fun `invoke should pass filter to repository`() = runTest { + val fakeRepository = FakeLogStreamRepository(emptyList()) + val useCase = ObserveLogStreamUseCase(fakeRepository) + val customFilter = LogFilter( + tags = setOf("CustomTag"), + minLogLevel = LogLevel.WARNING + ) + + useCase("device-456", customFilter).toList() + + assertEquals("device-456", fakeRepository.lastDeviceSerial) + assertEquals(customFilter, fakeRepository.lastFilter) + } + + @Test + fun `invoke should propagate errors from repository`() = runTest { + val fakeRepository = FakeLogStreamRepository( + entries = emptyList(), + shouldThrow = true + ) + val useCase = ObserveLogStreamUseCase(fakeRepository) + + try { + useCase("device-123", LogFilter()).toList() + assertTrue(false, "Should have thrown exception") + } catch (e: RuntimeException) { + assertEquals("Stream failed", e.message) + } + } +} + +/** + * Fake implementation of LogStreamRepository for testing + */ +private class FakeLogStreamRepository( + private val entries: List, + private val shouldThrow: Boolean = false +) : LogStreamRepository { + + var lastDeviceSerial: String? = null + private set + var lastFilter: LogFilter? = null + private set + + override fun observeLogStream( + deviceSerial: String, + filter: LogFilter + ): Flow { + lastDeviceSerial = deviceSerial + lastFilter = filter + + return flow { + if (shouldThrow) { + throw RuntimeException("Stream failed") + } + entries.forEach { emit(it) } + } + } +}