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
10 changes: 9 additions & 1 deletion composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import com.manjee.linkops.di.AppContainer
import com.manjee.linkops.domain.model.ShortcutAction
import com.manjee.linkops.ui.component.KeyboardShortcutHandler
import com.manjee.linkops.ui.component.ShortcutsHelpDialog
Expand All @@ -38,6 +39,7 @@ 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 kotlinx.coroutines.runBlocking
import org.jetbrains.compose.ui.tooling.preview.Preview

/**
Expand Down Expand Up @@ -73,9 +75,15 @@ fun App() {

var showShortcutsDialog by remember { mutableStateOf(false) }

// Cleanup ViewModels when composable leaves composition
// Cleanup ViewModels when composable leaves composition.
// The Ktor local server must be stopped synchronously BEFORE the ViewModel's
// viewModelScope is cancelled — otherwise stopServer() never runs and the
// port stays bound until the OS reclaims it on process exit.
DisposableEffect(Unit) {
onDispose {
runBlocking {
runCatching { AppContainer.stopLocalServerUseCase() }
}
mainViewModel.onCleared()
diagnosticsViewModel.onCleared()
manifestAnalyzerViewModel.onCleared()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ class VerificationFailureAnalyzer {
"Use the Digital Asset Links validator to check the format."
)
}
AssetLinksStatus.INVALID_CONTENT_TYPE -> {
reasons.add(FailureReason.ASSET_LINKS_INVALID_CONTENT_TYPE)
suggestions.add(
"The assetlinks.json at $domain is served with the wrong Content-Type. " +
"Configure the server to return 'application/json' so Android's " +
"verifier accepts the response."
)
}
AssetLinksStatus.NETWORK_ERROR -> {
reasons.add(FailureReason.ASSET_LINKS_NETWORK_ERROR)
suggestions.add(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ class DeviceRepositoryImpl(
}
}.distinctUntilChanged()

override suspend fun refreshDevices(): Result<List<Device>> {
return adbExecutor.execute("devices -l")
.mapCatching { output ->
deviceMapper.parseDeviceList(output)
.map { device -> enrichDeviceInfo(device) }
}
}

override suspend fun getDevice(serialNumber: String): Result<Device> {
return adbExecutor.execute("devices -l")
.mapCatching { output ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package com.manjee.linkops.data.repository

import com.manjee.linkops.domain.model.Favorite
import com.manjee.linkops.domain.repository.FavoriteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -34,6 +38,7 @@ class FavoriteRepositoryImpl(
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
private val storageFile: File = storageFile
private val _favorites = MutableStateFlow<List<FavoriteDto>>(emptyList())
private val mutex = Mutex()

init {
_favorites.value = loadFromDisk()
Expand All @@ -43,8 +48,8 @@ class FavoriteRepositoryImpl(
return _favorites.map { dtos -> dtos.map { it.toDomain() }.sortedByDescending { it.createdAt } }
}

override suspend fun addFavorite(uri: String, name: String): Result<Favorite> {
return runCatching {
override suspend fun addFavorite(uri: String, name: String): Result<Favorite> = mutex.withLock {
runCatching {
val existing = _favorites.value
if (existing.any { it.uri == uri }) {
throw IllegalArgumentException("URI already exists in favorites: $uri")
Expand All @@ -62,8 +67,8 @@ class FavoriteRepositoryImpl(
}
}

override suspend fun removeFavorite(id: String): Result<Unit> {
return runCatching {
override suspend fun removeFavorite(id: String): Result<Unit> = mutex.withLock {
runCatching {
val existing = _favorites.value
val updated = existing.filter { it.id != id }
if (updated.size == existing.size) {
Expand All @@ -87,7 +92,7 @@ class FavoriteRepositoryImpl(
}
}

private fun saveToDisk(favorites: List<FavoriteDto>) {
private suspend fun saveToDisk(favorites: List<FavoriteDto>) = withContext(Dispatchers.IO) {
storageFile.parentFile?.mkdirs()
storageFile.writeText(json.encodeToString(favorites))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,144 +122,156 @@ class LocalHostingRepositoryImpl(
packageName: String,
config: LocalHostingConfig
): Flow<VerificationStep> = flow {
// Step 1: Start local server
emit(step("Start local server", StepStatus.IN_PROGRESS))
val startResult = startServer(config)
if (startResult.isFailure) {
// Track whether the server is still running so finally can clean up on any abnormal exit
// (exception during polling, consumer cancellation, etc.). Without this the server could
// leak and _serverStatus stays RUNNING forever.
var serverStarted = false
try {
// Step 1: Start local server
emit(step("Start local server", StepStatus.IN_PROGRESS))
val startResult = startServer(config)
if (startResult.isFailure) {
emit(
step(
"Start local server",
StepStatus.FAILURE,
startResult.exceptionOrNull()?.message ?: "Failed to start server"
)
)
return@flow
}
serverStarted = true
emit(
step(
"Start local server",
StepStatus.FAILURE,
startResult.exceptionOrNull()?.message ?: "Failed to start server"
StepStatus.SUCCESS,
"Server running at ${config.serverUrl}"
)
)
return@flow
}
emit(
step(
"Start local server",
StepStatus.SUCCESS,
"Server running at ${config.serverUrl}"
)
)

// Step 2: Get SDK level
emit(step("Check device SDK level", StepStatus.IN_PROGRESS))
val sdkLevel = getSdkLevel(deviceSerial).getOrElse { error ->
// Step 2: Get SDK level
emit(step("Check device SDK level", StepStatus.IN_PROGRESS))
val sdkLevel = getSdkLevel(deviceSerial).getOrElse { error ->
emit(
step(
"Check device SDK level",
StepStatus.FAILURE,
error.message ?: "Failed to get SDK level"
)
)
return@flow
}
emit(
step(
"Check device SDK level",
StepStatus.FAILURE,
error.message ?: "Failed to get SDK level"
StepStatus.SUCCESS,
"SDK level: $sdkLevel"
)
)
stopServer()
return@flow
}
emit(
step(
"Check device SDK level",
StepStatus.SUCCESS,
"SDK level: $sdkLevel"
)
)

// Step 3: Trigger re-verification
emit(step("Trigger re-verification", StepStatus.IN_PROGRESS))
val strategy = strategyFactory.create(sdkLevel)
val reverifyCommand = strategy.forceReverifyCommand(packageName)
val reverifyResult = adbExecutor.executeOnDevice(deviceSerial, reverifyCommand)
if (reverifyResult.isFailure) {
// Step 3: Trigger re-verification
emit(step("Trigger re-verification", StepStatus.IN_PROGRESS))
val strategy = strategyFactory.create(sdkLevel)
val reverifyCommand = strategy.forceReverifyCommand(packageName)
val reverifyResult = adbExecutor.executeOnDevice(deviceSerial, reverifyCommand)
if (reverifyResult.isFailure) {
emit(
step(
"Trigger re-verification",
StepStatus.FAILURE,
reverifyResult.exceptionOrNull()?.message ?: "Failed to trigger re-verification"
)
)
return@flow
}
emit(
step(
"Trigger re-verification",
StepStatus.FAILURE,
reverifyResult.exceptionOrNull()?.message ?: "Failed to trigger re-verification"
StepStatus.SUCCESS,
"Re-verification triggered for $packageName"
)
)
stopServer()
return@flow
}
emit(
step(
"Trigger re-verification",
StepStatus.SUCCESS,
"Re-verification triggered for $packageName"
)
)

// Step 4: Wait and poll for verification results
emit(step("Wait for verification", StepStatus.IN_PROGRESS, "Polling..."))
delay(VERIFICATION_POLL_DELAY_MS)
// Step 4: Wait and poll for verification results
emit(step("Wait for verification", StepStatus.IN_PROGRESS, "Polling..."))
delay(VERIFICATION_POLL_DELAY_MS)

val appLinksCommand = strategy.getAppLinksCommand(packageName)
var lastStatus = ""
val appLinksCommand = strategy.getAppLinksCommand(packageName)
var lastStatus = ""

for (i in 1..VERIFICATION_MAX_POLLS) {
val pollResult = adbExecutor.executeOnDevice(deviceSerial, appLinksCommand)
if (pollResult.isSuccess) {
val output = pollResult.getOrThrow()
if (output != lastStatus) {
lastStatus = output
emit(
step(
"Wait for verification",
StepStatus.IN_PROGRESS,
"Poll $i/$VERIFICATION_MAX_POLLS: checking status..."
for (i in 1..VERIFICATION_MAX_POLLS) {
val pollResult = adbExecutor.executeOnDevice(deviceSerial, appLinksCommand)
if (pollResult.isSuccess) {
val output = pollResult.getOrThrow()
if (output != lastStatus) {
lastStatus = output
emit(
step(
"Wait for verification",
StepStatus.IN_PROGRESS,
"Poll $i/$VERIFICATION_MAX_POLLS: checking status..."
)
)
)
}

if (output.contains("verified") || output.contains("1024")) {
emit(
step(
"Wait for verification",
StepStatus.SUCCESS,
"Verification completed"
)
)
break
}
}

if (output.contains("verified") || output.contains("1024")) {
if (i == VERIFICATION_MAX_POLLS) {
emit(
step(
"Wait for verification",
StepStatus.SUCCESS,
"Verification completed"
StepStatus.FAILURE,
"Verification did not complete within the polling window"
)
)
break
}

delay(VERIFICATION_POLL_DELAY_MS)
}

if (i == VERIFICATION_MAX_POLLS) {
// Step 5: Retrieve final results
emit(step("Retrieve final results", StepStatus.IN_PROGRESS))
val finalResult = adbExecutor.executeOnDevice(deviceSerial, appLinksCommand)
if (finalResult.isSuccess) {
emit(
step(
"Retrieve final results",
StepStatus.SUCCESS,
finalResult.getOrThrow().trim()
)
)
} else {
emit(
step(
"Wait for verification",
"Retrieve final results",
StepStatus.FAILURE,
"Verification did not complete within the polling window"
finalResult.exceptionOrNull()?.message ?: "Failed to retrieve results"
)
)
}

delay(VERIFICATION_POLL_DELAY_MS)
}

// Step 5: Retrieve final results
emit(step("Retrieve final results", StepStatus.IN_PROGRESS))
val finalResult = adbExecutor.executeOnDevice(deviceSerial, appLinksCommand)
if (finalResult.isSuccess) {
emit(
step(
"Retrieve final results",
StepStatus.SUCCESS,
finalResult.getOrThrow().trim()
)
)
} else {
emit(
step(
"Retrieve final results",
StepStatus.FAILURE,
finalResult.exceptionOrNull()?.message ?: "Failed to retrieve results"
)
)
// Step 6: Stop server (normal completion path)
emit(step("Stop local server", StepStatus.IN_PROGRESS))
stopServer()
serverStarted = false
emit(step("Stop local server", StepStatus.SUCCESS, "Server stopped"))
} finally {
if (serverStarted) {
// Best-effort cleanup for abnormal exits (early return, exception, cancellation).
// Emitting from finally is unsafe if the flow was cancelled, so we just stop quietly.
runCatching { stopServer() }
}
}

// Step 6: Stop server
emit(step("Stop local server", StepStatus.IN_PROGRESS))
stopServer()
emit(step("Stop local server", StepStatus.SUCCESS, "Server stopped"))
}

private fun step(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class VerificationDiagnosticsRepositoryImpl(
ValidationStatus.NETWORK_ERROR -> AssetLinksStatus.NETWORK_ERROR
ValidationStatus.REDIRECT -> AssetLinksStatus.REDIRECT
ValidationStatus.FINGERPRINT_MISMATCH -> AssetLinksStatus.INVALID_JSON
ValidationStatus.INVALID_CONTENT_TYPE -> AssetLinksStatus.VALID
ValidationStatus.INVALID_CONTENT_TYPE -> AssetLinksStatus.INVALID_CONTENT_TYPE
null -> AssetLinksStatus.NETWORK_ERROR
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,10 @@ interface AdbCommandStrategy {
class Android11Strategy : AdbCommandStrategy {

override fun getAppLinksCommand(packageName: String?): String {
return if (packageName != null) {
"dumpsys package domain-preferred-apps | grep -A 5 $packageName"
} else {
"dumpsys package domain-preferred-apps"
}
// Note: package filtering is performed by DumpsysParser/caller in Kotlin.
// We don't shell-pipe to `grep` because ProcessBuilder doesn't go through a shell —
// `|` would be passed to `grep` as a literal argument and the command would fail.
return "dumpsys package domain-preferred-apps"
}

override fun forceReverifyCommand(packageName: String): String {
Expand Down
Loading
Loading