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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
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.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
Expand All @@ -26,6 +27,8 @@ 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.batchtest.BatchTestScreen
import com.manjee.linkops.ui.screen.batchtest.BatchTestViewModel
import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerScreen
import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerViewModel
import com.manjee.linkops.ui.theme.LinkOpsTheme
Expand Down Expand Up @@ -56,6 +59,7 @@ fun App() {
val manifestAnalyzerViewModel = remember { ManifestAnalyzerViewModel() }
val verificationDeepDiveViewModel = remember { VerificationDeepDiveViewModel() }
val logStreamViewModel = remember { LogStreamViewModel() }
val batchTestViewModel = remember { BatchTestViewModel() }
val keyboardShortcutHandler = remember { KeyboardShortcutHandler() }
val searchFocusTrigger = remember { mutableStateOf(0) }

Expand All @@ -69,6 +73,7 @@ fun App() {
manifestAnalyzerViewModel.onCleared()
verificationDeepDiveViewModel.onCleared()
logStreamViewModel.onCleared()
batchTestViewModel.onCleared()
}
}

Expand Down Expand Up @@ -154,6 +159,14 @@ fun App() {
)
}

Screen.BatchTest -> {
val mainUiState by mainViewModel.uiState.collectAsState()
BatchTestScreen(
viewModel = batchTestViewModel,
devices = mainUiState.devices
)
}

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

NavigationRailItem(
icon = { Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = "Batch Test") },
label = { Text("Batch Test") },
selected = currentScreen == Screen.BatchTest,
onClick = { onNavigate(Screen.BatchTest) }
)

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

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

import com.manjee.linkops.domain.model.DeepLinkTemplate
import com.manjee.linkops.domain.usecase.batchtest.TemplateUriResolver

/**
* Handles variable substitution in deep link URI templates
*
* Replaces `{variable}` placeholders with concrete values.
* Supports combinatorial expansion when multiple variables have multiple values.
*/
class ParameterSubstituter : TemplateUriResolver {

/**
* Resolves all URIs from a list of templates and variable values
*
* @param templates List of deep link templates
* @param variables Map of variable names to their possible values
* @return List of fully resolved URI strings
*/
override fun resolveAll(
templates: List<DeepLinkTemplate>,
variables: Map<String, List<String>>
): List<String> {
if (templates.isEmpty()) return emptyList()

val combinations = generateCombinations(variables)

return templates.flatMap { template ->
if (!template.hasPlaceholders || combinations.isEmpty()) {
listOf(template.uri)
} else {
combinations.map { combination -> substitute(template.uri, combination) }
}
}
}

/**
* Substitutes placeholders in a URI template with values
*
* @param uriTemplate URI with {variable} placeholders
* @param values Map of variable name to concrete value
* @return Resolved URI string
*/
fun substitute(uriTemplate: String, values: Map<String, String>): String {
var result = uriTemplate
values.forEach { (key, value) ->
result = result.replace("{$key}", value)
}
return result
}

/**
* Generates Cartesian product of variable values
*
* @param variables Map of variable names to their possible values
* @return List of all possible variable combinations
*/
fun generateCombinations(
variables: Map<String, List<String>>
): List<Map<String, String>> {
if (variables.isEmpty()) return emptyList()

val entries = variables.entries.toList()
val nonEmpty = entries.filter { it.value.isNotEmpty() }
if (nonEmpty.isEmpty()) return emptyList()

return nonEmpty.fold(listOf(emptyMap())) { acc, (key, values) ->
acc.flatMap { existing ->
values.map { value -> existing + (key to value) }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.manjee.linkops.data.mapper

import com.manjee.linkops.domain.model.DeepLinkTemplate
import com.manjee.linkops.domain.model.TestScenario
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

/**
* Maps between domain TestScenario and serializable DTO for JSON export/import
*/
class ScenarioMapper {

private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}

/**
* Serializes a TestScenario to JSON string
*
* @param scenario Domain model
* @return JSON string representation
*/
fun toJson(scenario: TestScenario): String {
val dto = TestScenarioDto(
id = scenario.id,
name = scenario.name,
description = scenario.description,
templates = scenario.templates.map { template ->
DeepLinkTemplateDto(
uri = template.uri,
name = template.name,
expectedActivity = template.expectedActivity
)
},
variables = scenario.variables,
delayBetweenLaunchesMs = scenario.delayBetweenLaunchesMs
)
return json.encodeToString(dto)
}

/**
* Deserializes a JSON string to TestScenario
*
* @param jsonString JSON string
* @return Domain model
*/
fun fromJson(jsonString: String): TestScenario {
val dto = json.decodeFromString<TestScenarioDto>(jsonString)
return TestScenario(
id = dto.id,
name = dto.name,
description = dto.description,
templates = dto.templates.map { templateDto ->
DeepLinkTemplate(
uri = templateDto.uri,
name = templateDto.name,
expectedActivity = templateDto.expectedActivity
)
},
variables = dto.variables,
delayBetweenLaunchesMs = dto.delayBetweenLaunchesMs
)
}
}

/**
* Serializable DTO for TestScenario JSON export/import
*/
@Serializable
private data class TestScenarioDto(
val id: String,
val name: String,
val description: String = "",
val templates: List<DeepLinkTemplateDto> = emptyList(),
val variables: Map<String, List<String>> = emptyMap(),
val delayBetweenLaunchesMs: Long = TestScenario.DEFAULT_DELAY_MS
)

/**
* Serializable DTO for DeepLinkTemplate
*/
@Serializable
private data class DeepLinkTemplateDto(
val uri: String,
val name: String = "",
val expectedActivity: String = ""
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.manjee.linkops.data.parser

/**
* Parser for `adb shell am start` command output
*
* Example success output:
* ```
* Starting: Intent { act=android.intent.action.VIEW dat=myapp://product/123 }
* Status: ok
* Activity: com.example.app/.ProductActivity
* ThisTime: 234
* TotalTime: 456
* WaitTime: 567
* Complete
* ```
*
* Example error output (no matching Activity):
* ```
* Starting: Intent { act=android.intent.action.VIEW dat=invalid://link }
* Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW dat=invalid://link flg=0x10000000 }
* ```
*
* Example error output (device error):
* ```
* error: device not found
* ```
*/
class AmStartOutputParser {

/**
* Parses am start output into a structured result
*
* @param output Raw stdout from `adb shell am start -W` command
* @return ParsedAmStartResult containing activity or error info
*/
fun parse(output: String): ParsedAmStartResult {
if (output.isBlank()) {
return ParsedAmStartResult.Error("Empty output from am start command")
}

val lines = output.lines().map { it.trim() }

// Check for device-level errors
lines.firstOrNull { it.startsWith("error:") }?.let { errorLine ->
return ParsedAmStartResult.Error(errorLine)
}

// Check for Permission denied
lines.firstOrNull {
it.contains("Permission denied", ignoreCase = true) ||
it.contains("Operation not permitted", ignoreCase = true)
}?.let { errorLine ->
return ParsedAmStartResult.Error(errorLine)
}

// Check for Error line from am start
lines.firstOrNull { it.startsWith("Error:") }?.let { errorLine ->
return ParsedAmStartResult.Error(errorLine.removePrefix("Error:").trim())
}

// Look for Activity line
val activityLine = lines.firstOrNull { it.startsWith("Activity:") }
val activity = activityLine?.removePrefix("Activity:")?.trim()

// Look for Status line
val statusLine = lines.firstOrNull { it.startsWith("Status:") }
val status = statusLine?.removePrefix("Status:")?.trim()

// Check for Warning (e.g., Activity not started)
val warningLine = lines.firstOrNull { it.startsWith("Warning:") }

return when {
status == "ok" && activity != null -> ParsedAmStartResult.Success(activity)
activity != null -> ParsedAmStartResult.Success(activity)
warningLine != null -> ParsedAmStartResult.Error(
warningLine.removePrefix("Warning:").trim()
)
else -> ParsedAmStartResult.Error(
"Unable to parse am start output: ${output.take(200)}"
)
}
}
}

/**
* Parsed result from am start command output
*/
sealed class ParsedAmStartResult {
/**
* Activity was successfully resolved
*
* @param resolvedActivity Fully qualified Activity class name
*/
data class Success(val resolvedActivity: String) : ParsedAmStartResult()

/**
* Launch failed with an error
*
* @param message Error description
*/
data class Error(val message: String) : ParsedAmStartResult()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.manjee.linkops.data.repository

import com.manjee.linkops.data.mapper.ScenarioMapper
import com.manjee.linkops.data.parser.AmStartOutputParser
import com.manjee.linkops.data.parser.ParsedAmStartResult
import com.manjee.linkops.domain.model.LaunchResult
import com.manjee.linkops.domain.model.TestScenario
import com.manjee.linkops.domain.repository.BatchTestRepository
import com.manjee.linkops.infrastructure.adb.AdbShellExecutor

/**
* Implementation of BatchTestRepository using ADB commands
*/
class BatchTestRepositoryImpl(
private val adbExecutor: AdbShellExecutor,
private val amStartOutputParser: AmStartOutputParser,
private val scenarioMapper: ScenarioMapper
) : BatchTestRepository {

override suspend fun launchDeepLink(
deviceSerial: String,
uri: String
): Result<LaunchResult> {
val startTime = System.currentTimeMillis()
val command = buildAmStartCommand(uri)

return adbExecutor.executeOnDevice(deviceSerial, command)
.mapCatching { output ->
val elapsed = System.currentTimeMillis() - startTime
when (val parsed = amStartOutputParser.parse(output)) {
is ParsedAmStartResult.Success -> LaunchResult.Success(
uri = uri,
resolvedActivity = parsed.resolvedActivity,
timeTakenMs = elapsed
)
is ParsedAmStartResult.Error -> LaunchResult.Error(
uri = uri,
errorMessage = parsed.message,
timeTakenMs = elapsed
)
}
}
.recoverCatching { error ->
val elapsed = System.currentTimeMillis() - startTime
LaunchResult.Error(
uri = uri,
errorMessage = error.message ?: "Unknown ADB error",
timeTakenMs = elapsed
)
}
}

override suspend fun exportScenario(scenario: TestScenario): Result<String> {
return runCatching {
scenarioMapper.toJson(scenario)
}
}

override suspend fun importScenario(json: String): Result<TestScenario> {
return runCatching {
scenarioMapper.fromJson(json)
}
}

private fun buildAmStartCommand(uri: String): String {
return "am start -W -a android.intent.action.VIEW -d \"$uri\""
}
}
Loading