diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt index 922dd63..4e109c6 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt @@ -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 @@ -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 /** @@ -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() diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/analyzer/VerificationFailureAnalyzer.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/analyzer/VerificationFailureAnalyzer.kt index 95fb042..6867d58 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/analyzer/VerificationFailureAnalyzer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/analyzer/VerificationFailureAnalyzer.kt @@ -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( diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/DeviceRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/DeviceRepositoryImpl.kt index f0b027c..a1fc167 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/DeviceRepositoryImpl.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/DeviceRepositoryImpl.kt @@ -30,6 +30,14 @@ class DeviceRepositoryImpl( } }.distinctUntilChanged() + override suspend fun refreshDevices(): Result> { + return adbExecutor.execute("devices -l") + .mapCatching { output -> + deviceMapper.parseDeviceList(output) + .map { device -> enrichDeviceInfo(device) } + } + } + override suspend fun getDevice(serialNumber: String): Result { return adbExecutor.execute("devices -l") .mapCatching { output -> diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/FavoriteRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/FavoriteRepositoryImpl.kt index d25d2bb..b7845a1 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/FavoriteRepositoryImpl.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/FavoriteRepositoryImpl.kt @@ -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 @@ -34,6 +38,7 @@ class FavoriteRepositoryImpl( private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } private val storageFile: File = storageFile private val _favorites = MutableStateFlow>(emptyList()) + private val mutex = Mutex() init { _favorites.value = loadFromDisk() @@ -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 { - return runCatching { + override suspend fun addFavorite(uri: String, name: String): Result = mutex.withLock { + runCatching { val existing = _favorites.value if (existing.any { it.uri == uri }) { throw IllegalArgumentException("URI already exists in favorites: $uri") @@ -62,8 +67,8 @@ class FavoriteRepositoryImpl( } } - override suspend fun removeFavorite(id: String): Result { - return runCatching { + override suspend fun removeFavorite(id: String): Result = mutex.withLock { + runCatching { val existing = _favorites.value val updated = existing.filter { it.id != id } if (updated.size == existing.size) { @@ -87,7 +92,7 @@ class FavoriteRepositoryImpl( } } - private fun saveToDisk(favorites: List) { + private suspend fun saveToDisk(favorites: List) = withContext(Dispatchers.IO) { storageFile.parentFile?.mkdirs() storageFile.writeText(json.encodeToString(favorites)) } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/LocalHostingRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/LocalHostingRepositoryImpl.kt index 116b159..53af68b 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/LocalHostingRepositoryImpl.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/LocalHostingRepositoryImpl.kt @@ -122,144 +122,156 @@ class LocalHostingRepositoryImpl( packageName: String, config: LocalHostingConfig ): Flow = 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( diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/VerificationDiagnosticsRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/VerificationDiagnosticsRepositoryImpl.kt index 9cc1636..09cab99 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/VerificationDiagnosticsRepositoryImpl.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/VerificationDiagnosticsRepositoryImpl.kt @@ -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 } } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/strategy/AdbCommandStrategy.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/strategy/AdbCommandStrategy.kt index 3b490de..92b1f9a 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/strategy/AdbCommandStrategy.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/strategy/AdbCommandStrategy.kt @@ -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 { 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 4ed7448..58f395f 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -46,6 +46,7 @@ import com.manjee.linkops.domain.usecase.batchtest.ExportScenarioUseCase import com.manjee.linkops.domain.usecase.batchtest.ImportScenarioUseCase import com.manjee.linkops.domain.usecase.batchtest.ResolveTemplateUrisUseCase import com.manjee.linkops.domain.usecase.device.DetectDevicesUseCase +import com.manjee.linkops.domain.usecase.device.RefreshDevicesUseCase import com.manjee.linkops.domain.usecase.diagnostics.AnalyzeVerificationUseCase import com.manjee.linkops.domain.usecase.diagnostics.DetectCollisionsUseCase import com.manjee.linkops.domain.usecase.diagnostics.ValidateAssetLinksUseCase @@ -242,6 +243,10 @@ object AppContainer { DetectDevicesUseCase(deviceRepository) } + val refreshDevicesUseCase: RefreshDevicesUseCase by lazy { + RefreshDevicesUseCase(deviceRepository) + } + // UseCases - App Links val getAppLinksUseCase: GetAppLinksUseCase by lazy { GetAppLinksUseCase(appLinkRepository) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/VerificationDiagnostics.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/VerificationDiagnostics.kt index fa2fabd..c18c686 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/VerificationDiagnostics.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/VerificationDiagnostics.kt @@ -87,6 +87,7 @@ enum class AssetLinksStatus { VALID, NOT_FOUND, INVALID_JSON, + INVALID_CONTENT_TYPE, NETWORK_ERROR, REDIRECT, NOT_CHECKED @@ -98,6 +99,7 @@ enum class AssetLinksStatus { enum class FailureReason { ASSET_LINKS_MISSING, ASSET_LINKS_INVALID_JSON, + ASSET_LINKS_INVALID_CONTENT_TYPE, ASSET_LINKS_NETWORK_ERROR, ASSET_LINKS_REDIRECT, FINGERPRINT_MISMATCH, diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/DeviceRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/DeviceRepository.kt index 4e55455..ac96b85 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/DeviceRepository.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/DeviceRepository.kt @@ -14,6 +14,13 @@ interface DeviceRepository { */ fun observeDevices(): Flow> + /** + * Performs a one-shot fetch of currently connected devices, enriched with OS metadata. + * Used by manual refresh actions (button / shortcut) that don't want to subscribe to + * the polling Flow. + */ + suspend fun refreshDevices(): Result> + /** * Gets a specific device by serial number * @param serialNumber Device serial number diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/device/RefreshDevicesUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/device/RefreshDevicesUseCase.kt new file mode 100644 index 0000000..d802d11 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/device/RefreshDevicesUseCase.kt @@ -0,0 +1,17 @@ +package com.manjee.linkops.domain.usecase.device + +import com.manjee.linkops.domain.model.Device +import com.manjee.linkops.domain.repository.DeviceRepository + +/** + * UseCase for one-shot device refresh (manual button / keyboard shortcut). + * + * Distinct from [DetectDevicesUseCase], which exposes a polling Flow. + */ +class RefreshDevicesUseCase( + private val deviceRepository: DeviceRepository +) { + suspend operator fun invoke(): Result> { + return deviceRepository.refreshDevices() + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbShellExecutor.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbShellExecutor.kt index 3253589..cd6bef9 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbShellExecutor.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbShellExecutor.kt @@ -153,13 +153,31 @@ class AdbShellExecutor( } /** - * Parses command string into individual arguments + * Parses command string into individual arguments. * - * Simple whitespace splitting. For complex commands with quoted strings, - * consider using proper shell tokenization. + * Honors double-quoted spans so arguments containing spaces stay intact + * (e.g. `am start ... -d "https://example.com/foo bar"`). Quotes themselves + * are stripped because ProcessBuilder doesn't go through a shell — passing + * the quote characters as part of the argument would corrupt the value. */ private fun parseCommand(command: String): List { - return command.split(" ").filter { it.isNotEmpty() } + val result = mutableListOf() + val current = StringBuilder() + var inQuotes = false + for (c in command) { + when { + c == '"' -> inQuotes = !inQuotes + c.isWhitespace() && !inQuotes -> { + if (current.isNotEmpty()) { + result.add(current.toString()) + current.clear() + } + } + else -> current.append(c) + } + } + if (current.isNotEmpty()) result.add(current.toString()) + return result } } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/VerificationDeepDiveScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/VerificationDeepDiveScreen.kt index cdf9e84..baba98b 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/VerificationDeepDiveScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/VerificationDeepDiveScreen.kt @@ -436,6 +436,7 @@ private fun AssetLinksStatusRow(status: AssetLinksStatus) { AssetLinksStatus.VALID -> "Valid" to LinkOpsColors.Success AssetLinksStatus.NOT_FOUND -> "Not Found (404)" to LinkOpsColors.Error AssetLinksStatus.INVALID_JSON -> "Invalid JSON" to LinkOpsColors.Error + AssetLinksStatus.INVALID_CONTENT_TYPE -> "Wrong Content-Type" to LinkOpsColors.Error AssetLinksStatus.NETWORK_ERROR -> "Network Error" to LinkOpsColors.Error AssetLinksStatus.REDIRECT -> "Redirect (not allowed)" to LinkOpsColors.Warning AssetLinksStatus.NOT_CHECKED -> "Not Checked" to LinkOpsColors.Unknown @@ -625,6 +626,7 @@ private fun formatFailureReason(reason: FailureReason): String { return when (reason) { FailureReason.ASSET_LINKS_MISSING -> "assetlinks.json not found" FailureReason.ASSET_LINKS_INVALID_JSON -> "assetlinks.json has invalid JSON" + FailureReason.ASSET_LINKS_INVALID_CONTENT_TYPE -> "assetlinks.json wrong Content-Type" FailureReason.ASSET_LINKS_NETWORK_ERROR -> "Cannot reach domain server" FailureReason.ASSET_LINKS_REDIRECT -> "assetlinks.json served via redirect" FailureReason.FINGERPRINT_MISMATCH -> "Certificate fingerprint mismatch" diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt index 7e7bd51..33d32ab 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/main/MainViewModel.kt @@ -1,6 +1,5 @@ package com.manjee.linkops.ui.screen.main -import com.manjee.linkops.data.mapper.DeviceMapper import com.manjee.linkops.di.AppContainer import com.manjee.linkops.domain.model.AppLink import com.manjee.linkops.domain.model.Device @@ -53,8 +52,6 @@ class MainViewModel { .map { it.joinToString("") } .stateIn(viewModelScope, SharingStarted.Eagerly, "Ready to test...\n") - private val deviceMapper = DeviceMapper() - init { checkAdbStatus() observeFavorites() @@ -137,67 +134,28 @@ class MainViewModel { _uiState.update { it.copy(isLoadingDevices = true, error = null) } appendLog("--- Fetching Devices ---") - try { - AppContainer.adbShellExecutor.execute("devices -l") - .onSuccess { output -> - appendLog(output) - - // Parse devices - val parsedDevices = deviceMapper.parseDeviceList(output) - - // Enrich each online device with OS version and SDK level - val enrichedDevices = parsedDevices.map { device -> - if (device.state == Device.DeviceState.ONLINE) { - enrichDevice(device) - } else { - device - } - } - - _uiState.update { - it.copy( - devices = enrichedDevices, - isLoadingDevices = false - ) - } - appendLog("Found ${enrichedDevices.size} device(s)") + AppContainer.refreshDevicesUseCase() + .onSuccess { devices -> + _uiState.update { + it.copy( + devices = devices, + isLoadingDevices = false + ) } - .onFailure { error -> - appendLog("Error: ${error.message}") - _uiState.update { - it.copy( - isLoadingDevices = false, - error = error.message - ) - } + appendLog("Found ${devices.size} device(s)") + } + .onFailure { error -> + appendLog("Error: ${error.message}") + _uiState.update { + it.copy( + isLoadingDevices = false, + error = error.message + ) } - } catch (e: Exception) { - appendLog("Error: ${e.message}") - _uiState.update { - it.copy( - isLoadingDevices = false, - error = e.message - ) } - } } } - /** - * Enrich device with OS version and SDK level - */ - private suspend fun enrichDevice(device: Device): Device { - val osVersion = AppContainer.adbShellExecutor - .executeOnDevice(device.serialNumber, "getprop ro.build.version.release") - .getOrNull()?.trim() ?: "Unknown" - - val sdkLevel = AppContainer.adbShellExecutor - .executeOnDevice(device.serialNumber, "getprop ro.build.version.sdk") - .getOrNull()?.trim()?.toIntOrNull() ?: 0 - - return device.copy(osVersion = osVersion, sdkLevel = sdkLevel) - } - /** * Select a device */