From df573e19b0effd267a91c3f2f8a0935527aa10d2 Mon Sep 17 00:00:00 2001
From: lucky
Date: Thu, 14 May 2026 19:29:15 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat(settings):=20implement=20Settings=20sc?=
=?UTF-8?q?reen=20(MVP)=20=E2=80=94=20theme,=20ADB=20override,=20hosting?=
=?UTF-8?q?=20&=20polling=20defaults?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the Dashboard fallback that was wired to Screen.Settings. Settings persist to
~/.linkops/settings.json with the same Mutex + Dispatchers.IO pattern as favorites.
Sections:
- Appearance: Light / Dark / System, applied instantly via LinkOpsTheme(darkTheme = ...)
- ADB: optional override path; AdbBinaryManager prefers it over the system PATH and
falls back gracefully if the file isn't executable
- Local Hosting defaults: default host/port seeded into LocalHostingViewModel on open
- Device detection: polling interval read on every DeviceRepositoryImpl tick so a
change takes effect on the next iteration without an app restart
Form behavior:
- Theme writes through immediately (visual feedback is the confirmation)
- Text fields stay local until Save; port and polling inputs are validated against
their declared ranges before the Save button enables
- Discard reverts the draft to the persisted value
Part of #33. README/Compose deprecated cleanup left for follow-up commits.
---
.../jvmMain/kotlin/com/manjee/linkops/App.kt | 19 +-
.../data/repository/DeviceRepositoryImpl.kt | 19 +-
.../data/repository/SettingsRepositoryImpl.kt | 121 ++++++++
.../com/manjee/linkops/di/AppContainer.kt | 21 +-
.../linkops/domain/model/AppSettings.kt | 37 +++
.../domain/repository/SettingsRepository.kt | 26 ++
.../settings/ObserveSettingsUseCase.kt | 11 +
.../usecase/settings/UpdateSettingsUseCase.kt | 12 +
.../infrastructure/adb/AdbBinaryManager.kt | 34 +-
.../localhosting/LocalHostingViewModel.kt | 12 +-
.../ui/screen/settings/SettingsScreen.kt | 291 ++++++++++++++++++
.../ui/screen/settings/SettingsViewModel.kt | 164 ++++++++++
12 files changed, 747 insertions(+), 20 deletions(-)
create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/SettingsRepositoryImpl.kt
create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/AppSettings.kt
create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/SettingsRepository.kt
create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/ObserveSettingsUseCase.kt
create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/UpdateSettingsUseCase.kt
create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsScreen.kt
create mode 100644 composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsViewModel.kt
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt
index 4e109c6..85e008b 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/App.kt
@@ -12,6 +12,7 @@ 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.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -19,6 +20,7 @@ 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.domain.model.ThemePreference
import com.manjee.linkops.ui.component.KeyboardShortcutHandler
import com.manjee.linkops.ui.component.ShortcutsHelpDialog
import com.manjee.linkops.ui.navigation.*
@@ -36,6 +38,8 @@ import com.manjee.linkops.ui.screen.localhosting.LocalHostingScreen
import com.manjee.linkops.ui.screen.localhosting.LocalHostingViewModel
import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerScreen
import com.manjee.linkops.ui.screen.manifest.ManifestAnalyzerViewModel
+import com.manjee.linkops.ui.screen.settings.SettingsScreen
+import com.manjee.linkops.ui.screen.settings.SettingsViewModel
import com.manjee.linkops.ui.screen.sniffer.IntentSnifferScreen
import com.manjee.linkops.ui.screen.sniffer.IntentSnifferViewModel
import com.manjee.linkops.ui.theme.LinkOpsTheme
@@ -70,6 +74,7 @@ fun App() {
val batchTestViewModel = remember { BatchTestViewModel() }
val localHostingViewModel = remember { LocalHostingViewModel() }
val intentSnifferViewModel = remember { IntentSnifferViewModel() }
+ val settingsViewModel = remember { SettingsViewModel() }
val keyboardShortcutHandler = remember { KeyboardShortcutHandler() }
val searchFocusTrigger = remember { mutableStateOf(0) }
@@ -92,10 +97,19 @@ fun App() {
batchTestViewModel.onCleared()
localHostingViewModel.onCleared()
intentSnifferViewModel.onCleared()
+ settingsViewModel.onCleared()
}
}
- LinkOpsTheme {
+ val settingsState by settingsViewModel.uiState.collectAsState()
+ val systemInDark = isSystemInDarkTheme()
+ val darkTheme = when (settingsState.draft.theme) {
+ ThemePreference.LIGHT -> false
+ ThemePreference.DARK -> true
+ ThemePreference.SYSTEM -> systemInDark
+ }
+
+ LinkOpsTheme(darkTheme = darkTheme) {
ProvideNavigationController(navController) {
CompositionLocalProvider(
LocalSearchFocusTrigger provides searchFocusTrigger
@@ -206,8 +220,7 @@ fun App() {
}
Screen.Settings -> {
- // TODO: Implement SettingsScreen
- MainScreen(viewModel = mainViewModel)
+ SettingsScreen(viewModel = settingsViewModel)
}
is Screen.AppLinksDetail -> {
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 a1fc167..c1676d1 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
@@ -1,8 +1,10 @@
package com.manjee.linkops.data.repository
import com.manjee.linkops.data.mapper.DeviceMapper
+import com.manjee.linkops.domain.model.AppSettings
import com.manjee.linkops.domain.model.Device
import com.manjee.linkops.domain.repository.DeviceRepository
+import com.manjee.linkops.domain.repository.SettingsRepository
import com.manjee.linkops.infrastructure.adb.AdbShellExecutor
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -15,21 +17,26 @@ import kotlin.time.Duration.Companion.seconds
*/
class DeviceRepositoryImpl(
private val adbExecutor: AdbShellExecutor,
- private val deviceMapper: DeviceMapper
+ private val deviceMapper: DeviceMapper,
+ private val settingsRepository: SettingsRepository? = null
) : DeviceRepository {
- companion object {
- private val POLLING_INTERVAL = 2.seconds
- }
-
override fun observeDevices(): Flow> = flow {
while (true) {
val devices = fetchDevices()
emit(devices)
- delay(POLLING_INTERVAL)
+ // Read the polling interval each tick so a user-side settings change takes
+ // effect on the very next iteration instead of waiting for app restart.
+ delay(currentPollingDelayMs())
}
}.distinctUntilChanged()
+ private fun currentPollingDelayMs(): Long {
+ val seconds = settingsRepository?.current?.devicePollingIntervalSeconds
+ ?: AppSettings.DEFAULT_POLLING_INTERVAL_SECONDS
+ return seconds.seconds.inWholeMilliseconds
+ }
+
override suspend fun refreshDevices(): Result> {
return adbExecutor.execute("devices -l")
.mapCatching { output ->
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/SettingsRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/SettingsRepositoryImpl.kt
new file mode 100644
index 0000000..dd5cce1
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/SettingsRepositoryImpl.kt
@@ -0,0 +1,121 @@
+package com.manjee.linkops.data.repository
+
+import com.manjee.linkops.domain.model.AppSettings
+import com.manjee.linkops.domain.model.ThemePreference
+import com.manjee.linkops.domain.repository.SettingsRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+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
+import java.io.File
+
+/**
+ * Serializable on-disk shape. Kept separate from the domain model so that future schema
+ * changes (added/removed fields, renames) can be handled by `ignoreUnknownKeys` and
+ * mapping logic without forcing a domain model breakage.
+ */
+@Serializable
+private data class AppSettingsDto(
+ val theme: String = ThemePreference.SYSTEM.name,
+ val adbPathOverride: String? = null,
+ val defaultHost: String = AppSettings.DEFAULT_HOST,
+ val defaultPort: Int = AppSettings.DEFAULT_PORT,
+ val devicePollingIntervalSeconds: Int = AppSettings.DEFAULT_POLLING_INTERVAL_SECONDS
+)
+
+/**
+ * JSON file-based settings repository.
+ *
+ * Persists to `~/.linkops/settings.json`. Mirrors the safety model established by
+ * FavoriteRepositoryImpl: a Mutex serializes read-modify-write across coroutines and
+ * all disk IO runs on `Dispatchers.IO`.
+ */
+class SettingsRepositoryImpl(
+ storageFile: File = resolveStorageFile()
+) : SettingsRepository {
+
+ private val json = Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ }
+ private val storageFile: File = storageFile
+ private val mutex = Mutex()
+ private val _settings = MutableStateFlow(loadFromDisk())
+
+ override fun observeSettings(): Flow = _settings.asStateFlow()
+
+ override val current: AppSettings
+ get() = _settings.value
+
+ override suspend fun update(transform: (AppSettings) -> AppSettings): Result =
+ mutex.withLock {
+ runCatching {
+ val updated = transform(_settings.value).normalized()
+ saveToDisk(updated)
+ _settings.value = updated
+ updated
+ }
+ }
+
+ private fun loadFromDisk(): AppSettings {
+ if (!storageFile.exists()) return AppSettings()
+ return try {
+ json.decodeFromString(storageFile.readText()).toDomain()
+ } catch (_: Exception) {
+ // Corrupt or incompatible file — fall back to defaults rather than crashing.
+ // Future write will overwrite the bad file.
+ AppSettings()
+ }
+ }
+
+ private suspend fun saveToDisk(settings: AppSettings) = withContext(Dispatchers.IO) {
+ storageFile.parentFile?.mkdirs()
+ storageFile.writeText(json.encodeToString(settings.toDto()))
+ }
+
+ private fun AppSettingsDto.toDomain(): AppSettings = AppSettings(
+ theme = runCatching { ThemePreference.valueOf(theme) }.getOrDefault(ThemePreference.SYSTEM),
+ adbPathOverride = adbPathOverride?.takeIf { it.isNotBlank() },
+ defaultHost = defaultHost.ifBlank { AppSettings.DEFAULT_HOST },
+ defaultPort = defaultPort,
+ devicePollingIntervalSeconds = devicePollingIntervalSeconds
+ ).normalized()
+
+ private fun AppSettings.toDto(): AppSettingsDto = AppSettingsDto(
+ theme = theme.name,
+ adbPathOverride = adbPathOverride,
+ defaultHost = defaultHost,
+ defaultPort = defaultPort,
+ devicePollingIntervalSeconds = devicePollingIntervalSeconds
+ )
+
+ /**
+ * Clamps numeric fields to their declared safe ranges. Defensive — the UI also
+ * validates input, but a corrupted settings file or a future schema migration
+ * shouldn't be able to push out-of-range values into the rest of the app.
+ */
+ private fun AppSettings.normalized(): AppSettings = copy(
+ defaultPort = defaultPort.coerceIn(AppSettings.MIN_PORT, AppSettings.MAX_PORT),
+ devicePollingIntervalSeconds = devicePollingIntervalSeconds.coerceIn(
+ AppSettings.MIN_POLLING_INTERVAL_SECONDS,
+ AppSettings.MAX_POLLING_INTERVAL_SECONDS
+ ),
+ adbPathOverride = adbPathOverride?.takeIf { it.isNotBlank() }
+ )
+
+ companion object {
+ private const val STORAGE_DIR = ".linkops"
+ private const val STORAGE_FILE = "settings.json"
+
+ fun resolveStorageFile(): File {
+ val homeDir = System.getProperty("user.home")
+ return File(homeDir, "$STORAGE_DIR/$STORAGE_FILE")
+ }
+ }
+}
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 58f395f..94fe827 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt
@@ -25,6 +25,7 @@ import com.manjee.linkops.data.repository.IntentPayloadRepositoryImpl
import com.manjee.linkops.data.repository.LocalHostingRepositoryImpl
import com.manjee.linkops.data.repository.LogStreamRepositoryImpl
import com.manjee.linkops.data.repository.ManifestRepositoryImpl
+import com.manjee.linkops.data.repository.SettingsRepositoryImpl
import com.manjee.linkops.data.repository.VerificationDiagnosticsRepositoryImpl
import com.manjee.linkops.data.strategy.AdbCommandStrategyFactory
import com.manjee.linkops.domain.repository.AppLinkRepository
@@ -37,6 +38,7 @@ import com.manjee.linkops.domain.repository.IntentPayloadRepository
import com.manjee.linkops.domain.repository.LocalHostingRepository
import com.manjee.linkops.domain.repository.LogStreamRepository
import com.manjee.linkops.domain.repository.ManifestRepository
+import com.manjee.linkops.domain.repository.SettingsRepository
import com.manjee.linkops.domain.repository.VerificationDiagnosticsRepository
import com.manjee.linkops.domain.usecase.applink.FireIntentUseCase
import com.manjee.linkops.domain.usecase.applink.ForceReverifyUseCase
@@ -59,6 +61,8 @@ import com.manjee.linkops.domain.usecase.localhosting.RunVerificationWorkflowUse
import com.manjee.linkops.domain.usecase.localhosting.StartLocalServerUseCase
import com.manjee.linkops.domain.usecase.localhosting.StopLocalServerUseCase
import com.manjee.linkops.domain.usecase.logstream.ObserveLogStreamUseCase
+import com.manjee.linkops.domain.usecase.settings.ObserveSettingsUseCase
+import com.manjee.linkops.domain.usecase.settings.UpdateSettingsUseCase
import com.manjee.linkops.domain.usecase.sniffer.CaptureIntentPayloadUseCase
import com.manjee.linkops.domain.usecase.sniffer.ComparePayloadsUseCase
import com.manjee.linkops.domain.usecase.manifest.AnalyzeManifestUseCase
@@ -84,7 +88,7 @@ object AppContainer {
// Infrastructure - ADB
val adbBinaryManager: AdbBinaryManager by lazy {
- AdbBinaryManager()
+ AdbBinaryManager(settingsRepository)
}
val adbShellExecutor: AdbShellExecutor by lazy {
@@ -176,7 +180,7 @@ object AppContainer {
// Repositories
val deviceRepository: DeviceRepository by lazy {
- DeviceRepositoryImpl(adbShellExecutor, deviceMapper)
+ DeviceRepositoryImpl(adbShellExecutor, deviceMapper, settingsRepository)
}
val appLinkRepository: AppLinkRepository by lazy {
@@ -222,6 +226,10 @@ object AppContainer {
FavoriteRepositoryImpl()
}
+ val settingsRepository: SettingsRepository by lazy {
+ SettingsRepositoryImpl()
+ }
+
val logStreamRepository: LogStreamRepository by lazy {
LogStreamRepositoryImpl(adbShellExecutor, logcatParser)
}
@@ -329,6 +337,15 @@ object AppContainer {
RemoveFavoriteUseCase(favoriteRepository)
}
+ // UseCases - Settings
+ val observeSettingsUseCase: ObserveSettingsUseCase by lazy {
+ ObserveSettingsUseCase(settingsRepository)
+ }
+
+ val updateSettingsUseCase: UpdateSettingsUseCase by lazy {
+ UpdateSettingsUseCase(settingsRepository)
+ }
+
// UseCases - Log Stream
val observeLogStreamUseCase: ObserveLogStreamUseCase by lazy {
ObserveLogStreamUseCase(logStreamRepository)
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/AppSettings.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/AppSettings.kt
new file mode 100644
index 0000000..bfa805f
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/AppSettings.kt
@@ -0,0 +1,37 @@
+package com.manjee.linkops.domain.model
+
+/**
+ * Persisted user preferences for LinkOps.
+ *
+ * Stored at `~/.linkops/settings.json`. Defaults match the values the app has been
+ * shipping with so that existing users see no behavior change until they explicitly
+ * change something.
+ */
+data class AppSettings(
+ val theme: ThemePreference = ThemePreference.SYSTEM,
+ /**
+ * Absolute path to an ADB binary the user wants to force.
+ * When null, [AdbBinaryManager] falls back to the system PATH then the bundled binary.
+ */
+ val adbPathOverride: String? = null,
+ val defaultHost: String = DEFAULT_HOST,
+ val defaultPort: Int = DEFAULT_PORT,
+ val devicePollingIntervalSeconds: Int = DEFAULT_POLLING_INTERVAL_SECONDS
+) {
+ companion object {
+ const val DEFAULT_HOST: String = "127.0.0.1"
+ const val DEFAULT_PORT: Int = 8080
+ const val DEFAULT_POLLING_INTERVAL_SECONDS: Int = 2
+
+ const val MIN_PORT: Int = 1
+ const val MAX_PORT: Int = 65535
+ const val MIN_POLLING_INTERVAL_SECONDS: Int = 1
+ const val MAX_POLLING_INTERVAL_SECONDS: Int = 60
+ }
+}
+
+enum class ThemePreference {
+ LIGHT,
+ DARK,
+ SYSTEM
+}
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/SettingsRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/SettingsRepository.kt
new file mode 100644
index 0000000..c939698
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/SettingsRepository.kt
@@ -0,0 +1,26 @@
+package com.manjee.linkops.domain.repository
+
+import com.manjee.linkops.domain.model.AppSettings
+import kotlinx.coroutines.flow.Flow
+
+interface SettingsRepository {
+ /**
+ * Observes the current settings. Emits immediately with the loaded value and then
+ * again on every persisted change.
+ */
+ fun observeSettings(): Flow
+
+ /**
+ * Synchronous snapshot of the current settings. Used by callers that can't suspend
+ * (e.g. [com.manjee.linkops.infrastructure.adb.AdbBinaryManager.getAdbPath] resolving
+ * an override before issuing an ADB command).
+ */
+ val current: AppSettings
+
+ /**
+ * Applies a transform to the current settings and persists the result.
+ * The transform must be pure (no side effects); the repository serializes concurrent
+ * updates so the read-modify-write is atomic.
+ */
+ suspend fun update(transform: (AppSettings) -> AppSettings): Result
+}
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/ObserveSettingsUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/ObserveSettingsUseCase.kt
new file mode 100644
index 0000000..8873839
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/ObserveSettingsUseCase.kt
@@ -0,0 +1,11 @@
+package com.manjee.linkops.domain.usecase.settings
+
+import com.manjee.linkops.domain.model.AppSettings
+import com.manjee.linkops.domain.repository.SettingsRepository
+import kotlinx.coroutines.flow.Flow
+
+class ObserveSettingsUseCase(
+ private val settingsRepository: SettingsRepository
+) {
+ operator fun invoke(): Flow = settingsRepository.observeSettings()
+}
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/UpdateSettingsUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/UpdateSettingsUseCase.kt
new file mode 100644
index 0000000..c19372c
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/settings/UpdateSettingsUseCase.kt
@@ -0,0 +1,12 @@
+package com.manjee.linkops.domain.usecase.settings
+
+import com.manjee.linkops.domain.model.AppSettings
+import com.manjee.linkops.domain.repository.SettingsRepository
+
+class UpdateSettingsUseCase(
+ private val settingsRepository: SettingsRepository
+) {
+ suspend operator fun invoke(
+ transform: (AppSettings) -> AppSettings
+ ): Result = settingsRepository.update(transform)
+}
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbBinaryManager.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbBinaryManager.kt
index ac2eb2b..912d7da 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbBinaryManager.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/adb/AdbBinaryManager.kt
@@ -1,5 +1,6 @@
package com.manjee.linkops.infrastructure.adb
+import com.manjee.linkops.domain.repository.SettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@@ -10,25 +11,42 @@ import java.util.zip.ZipInputStream
/**
* ADB binary auto-download and management
- * - Uses system ADB if installed
- * - Downloads from Google SDK repository if not available
+ * - Uses user-configured override if set
+ * - Falls back to system ADB if installed
+ * - Falls back to bundled / downloaded binary
+ *
+ * The settings repository is optional so that tests and headless tooling can construct
+ * this without wiring the full DI graph.
*/
-class AdbBinaryManager {
+class AdbBinaryManager(
+ private val settingsRepository: SettingsRepository? = null
+) {
private val os: String = System.getProperty("os.name").lowercase()
private val adbDir = File(System.getProperty("user.home"), ".linkops/adb")
/**
* Returns ADB binary path
- * 1. Search for ADB in system PATH
- * 2. Search in bundled directory
- * 3. Return null if download is required
+ * 1. User override from settings (if configured and the file is executable)
+ * 2. ADB on system PATH
+ * 3. Bundled / previously downloaded binary
+ * 4. null when a download is required
*/
fun getAdbPath(): String? {
- // 1. Search for ADB in system PATH
+ // 1. Honor explicit user override when it points to an executable file.
+ // Falling through silently on a broken override would hide a misconfiguration —
+ // but treating it as fatal would lock the user out of ADB entirely if they typo.
+ // The Settings UI surfaces the value so they can fix it.
+ val override = settingsRepository?.current?.adbPathOverride
+ if (!override.isNullOrBlank()) {
+ val file = File(override)
+ if (file.exists() && file.canExecute()) return file.absolutePath
+ }
+
+ // 2. Search for ADB in system PATH
val systemAdb = findSystemAdb()
if (systemAdb != null) return systemAdb
- // 2. Check bundled ADB directory
+ // 3. Check bundled ADB directory
val bundledAdb = getBundledAdbFile()
if (bundledAdb.exists() && bundledAdb.canExecute()) {
return bundledAdb.absolutePath
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingViewModel.kt
index 0275b56..c825ecd 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingViewModel.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingViewModel.kt
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.*
* UI State for Local Hosting Screen
*/
data class LocalHostingUiState(
- val host: String = "127.0.0.1",
+ val host: String = com.manjee.linkops.domain.model.AppSettings.DEFAULT_HOST,
val port: String = LocalHostingConfig.DEFAULT_PORT.toString(),
val packageName: String = "",
val fingerprint: String = "",
@@ -38,6 +38,16 @@ class LocalHostingViewModel {
val uiState: StateFlow = _uiState.asStateFlow()
init {
+ // Seed host/port from saved settings before observing server status so the form
+ // shows the user's last-saved defaults on open. Edits made in this screen stay
+ // local — we don't push them back into settings.
+ val settings = AppContainer.settingsRepository.current
+ _uiState.update {
+ it.copy(
+ host = settings.defaultHost,
+ port = settings.defaultPort.toString()
+ )
+ }
observeServerStatus()
}
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsScreen.kt
new file mode 100644
index 0000000..9026426
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsScreen.kt
@@ -0,0 +1,291 @@
+package com.manjee.linkops.ui.screen.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Brightness4
+import androidx.compose.material.icons.filled.Brightness6
+import androidx.compose.material.icons.filled.BrightnessAuto
+import androidx.compose.material.icons.filled.Devices
+import androidx.compose.material.icons.filled.Dns
+import androidx.compose.material.icons.filled.Palette
+import androidx.compose.material.icons.filled.Terminal
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.manjee.linkops.domain.model.AppSettings
+import com.manjee.linkops.domain.model.ThemePreference
+import com.manjee.linkops.ui.theme.LinkOpsColors
+
+/**
+ * Application settings screen.
+ *
+ * Four sections that mirror the categories users actually want to tune:
+ * appearance, ADB, local hosting defaults, and device polling. Theme is applied
+ * instantly because the visual feedback is the confirmation; everything else
+ * waits for an explicit Save so a half-typed port doesn't immediately break things.
+ */
+@Composable
+fun SettingsScreen(
+ viewModel: SettingsViewModel,
+ modifier: Modifier = Modifier
+) {
+ val state by viewModel.uiState.collectAsState()
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ Header(state = state, onSave = viewModel::save, onDiscard = viewModel::discardChanges)
+
+ AppearanceSection(
+ current = state.draft.theme,
+ onChange = viewModel::setTheme
+ )
+
+ AdbSection(
+ value = state.draft.adbPathOverride.orEmpty(),
+ onChange = viewModel::updateAdbPathOverride
+ )
+
+ LocalHostingSection(
+ host = state.draft.defaultHost,
+ portInput = state.portInput,
+ portValid = state.isPortInputValid,
+ onHostChange = viewModel::updateDefaultHost,
+ onPortChange = viewModel::updatePortInput
+ )
+
+ DeviceDetectionSection(
+ pollingInput = state.pollingInput,
+ pollingValid = state.isPollingInputValid,
+ onPollingChange = viewModel::updatePollingInput
+ )
+
+ state.error?.let { ErrorBanner(message = it, onDismiss = viewModel::clearError) }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
+
+@Composable
+private fun Header(
+ state: SettingsUiState,
+ onSave: () -> Unit,
+ onDiscard: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Settings",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = when {
+ state.isLoading -> "Loading…"
+ state.isDirty -> "Unsaved changes"
+ state.savedAt != null -> "Saved"
+ else -> "All preferences are persisted to ~/.linkops/settings.json"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (state.isDirty) {
+ TextButton(onClick = onDiscard) { Text("Discard") }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(onClick = onSave, enabled = state.canSave) { Text("Save") }
+ }
+ }
+}
+
+@Composable
+private fun AppearanceSection(
+ current: ThemePreference,
+ onChange: (ThemePreference) -> Unit
+) {
+ SettingsCard(
+ title = "Appearance",
+ icon = Icons.Default.Palette,
+ helper = "Theme is applied immediately."
+ ) {
+ val options = listOf(
+ ThemeOption(ThemePreference.LIGHT, "Light", Icons.Default.Brightness6),
+ ThemeOption(ThemePreference.DARK, "Dark", Icons.Default.Brightness4),
+ ThemeOption(ThemePreference.SYSTEM, "System", Icons.Default.BrightnessAuto)
+ )
+
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ options.forEachIndexed { index, option ->
+ SegmentedButton(
+ selected = option.value == current,
+ onClick = { onChange(option.value) },
+ shape = SegmentedButtonDefaults.itemShape(index, options.size),
+ icon = { Icon(option.icon, contentDescription = null) }
+ ) {
+ Text(option.label)
+ }
+ }
+ }
+ }
+}
+
+private data class ThemeOption(
+ val value: ThemePreference,
+ val label: String,
+ val icon: ImageVector
+)
+
+@Composable
+private fun AdbSection(
+ value: String,
+ onChange: (String) -> Unit
+) {
+ SettingsCard(
+ title = "ADB",
+ icon = Icons.Default.Terminal,
+ helper = "Leave empty to use the system PATH or the bundled binary."
+ ) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onChange,
+ label = { Text("Override path") },
+ placeholder = { Text("/usr/local/bin/adb") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+private fun LocalHostingSection(
+ host: String,
+ portInput: String,
+ portValid: Boolean,
+ onHostChange: (String) -> Unit,
+ onPortChange: (String) -> Unit
+) {
+ SettingsCard(
+ title = "Local Hosting defaults",
+ icon = Icons.Default.Dns,
+ helper = "Used as the initial values when you open the Local Hosting screen."
+ ) {
+ OutlinedTextField(
+ value = host,
+ onValueChange = onHostChange,
+ label = { Text("Default host") },
+ placeholder = { Text(AppSettings.DEFAULT_HOST) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ OutlinedTextField(
+ value = portInput,
+ onValueChange = onPortChange,
+ label = { Text("Default port") },
+ placeholder = { Text(AppSettings.DEFAULT_PORT.toString()) },
+ singleLine = true,
+ isError = !portValid,
+ supportingText = {
+ if (!portValid) {
+ Text("Enter a number between ${AppSettings.MIN_PORT} and ${AppSettings.MAX_PORT}.")
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+private fun DeviceDetectionSection(
+ pollingInput: String,
+ pollingValid: Boolean,
+ onPollingChange: (String) -> Unit
+) {
+ SettingsCard(
+ title = "Device detection",
+ icon = Icons.Default.Devices,
+ helper = "How often LinkOps polls ADB for connected devices. Lower is more responsive but uses more CPU."
+ ) {
+ OutlinedTextField(
+ value = pollingInput,
+ onValueChange = onPollingChange,
+ label = { Text("Polling interval (seconds)") },
+ singleLine = true,
+ isError = !pollingValid,
+ supportingText = {
+ if (!pollingValid) {
+ Text(
+ "Enter a number between ${AppSettings.MIN_POLLING_INTERVAL_SECONDS} " +
+ "and ${AppSettings.MAX_POLLING_INTERVAL_SECONDS}."
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+private fun SettingsCard(
+ title: String,
+ icon: ImageVector,
+ helper: String,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = helper,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ content()
+ }
+ }
+}
+
+@Composable
+private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = LinkOpsColors.ErrorLight),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(text = message, modifier = Modifier.weight(1f), color = LinkOpsColors.Error)
+ TextButton(onClick = onDismiss) { Text("Dismiss") }
+ }
+ }
+}
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..3b53fe8
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/settings/SettingsViewModel.kt
@@ -0,0 +1,164 @@
+package com.manjee.linkops.ui.screen.settings
+
+import com.manjee.linkops.di.AppContainer
+import com.manjee.linkops.domain.model.AppSettings
+import com.manjee.linkops.domain.model.ThemePreference
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * Form-local state for the Settings screen.
+ *
+ * `draft` is what the user is currently typing / toggling; `persisted` is the last
+ * value confirmed by the repository. We keep them separate so the user can edit
+ * text fields without each keystroke immediately writing to disk, and so we can
+ * tell when a field is dirty (draft != persisted).
+ */
+data class SettingsUiState(
+ val isLoading: Boolean = true,
+ val draft: AppSettings = AppSettings(),
+ val persisted: AppSettings = AppSettings(),
+ val portInput: String = AppSettings.DEFAULT_PORT.toString(),
+ val pollingInput: String = AppSettings.DEFAULT_POLLING_INTERVAL_SECONDS.toString(),
+ val error: String? = null,
+ val savedAt: Long? = null
+) {
+ val isDirty: Boolean
+ get() = draft != persisted
+
+ val isPortInputValid: Boolean
+ get() = portInput.toIntOrNull()
+ ?.let { it in AppSettings.MIN_PORT..AppSettings.MAX_PORT }
+ ?: false
+
+ val isPollingInputValid: Boolean
+ get() = pollingInput.toIntOrNull()
+ ?.let { it in AppSettings.MIN_POLLING_INTERVAL_SECONDS..AppSettings.MAX_POLLING_INTERVAL_SECONDS }
+ ?: false
+
+ val canSave: Boolean
+ get() = isDirty && isPortInputValid && isPollingInputValid
+}
+
+class SettingsViewModel {
+ private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ private val _uiState = MutableStateFlow(SettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ observeSettings()
+ }
+
+ private fun observeSettings() {
+ viewModelScope.launch {
+ AppContainer.observeSettingsUseCase().collect { settings ->
+ _uiState.update {
+ // Only reset the draft from persisted when the user has no dirty edits,
+ // otherwise we'd discard their in-progress typing every time the file changes.
+ if (it.isDirty) {
+ it.copy(persisted = settings, isLoading = false)
+ } else {
+ it.copy(
+ persisted = settings,
+ draft = settings,
+ portInput = settings.defaultPort.toString(),
+ pollingInput = settings.devicePollingIntervalSeconds.toString(),
+ isLoading = false
+ )
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Theme toggles persist immediately — there's no failure mode worth a confirm step
+ * and the visual feedback is instant.
+ */
+ fun setTheme(theme: ThemePreference) {
+ _uiState.update { it.copy(draft = it.draft.copy(theme = theme)) }
+ viewModelScope.launch {
+ AppContainer.updateSettingsUseCase { current -> current.copy(theme = theme) }
+ .onFailure { error ->
+ _uiState.update { it.copy(error = error.message ?: "Failed to save theme") }
+ }
+ }
+ }
+
+ fun updateAdbPathOverride(path: String) {
+ _uiState.update {
+ it.copy(draft = it.draft.copy(adbPathOverride = path.takeIf { p -> p.isNotBlank() }))
+ }
+ }
+
+ fun updateDefaultHost(host: String) {
+ _uiState.update { it.copy(draft = it.draft.copy(defaultHost = host)) }
+ }
+
+ fun updatePortInput(value: String) {
+ _uiState.update { state ->
+ state.copy(
+ portInput = value,
+ draft = value.toIntOrNull()?.let { state.draft.copy(defaultPort = it) } ?: state.draft
+ )
+ }
+ }
+
+ fun updatePollingInput(value: String) {
+ _uiState.update { state ->
+ state.copy(
+ pollingInput = value,
+ draft = value.toIntOrNull()
+ ?.let { state.draft.copy(devicePollingIntervalSeconds = it) }
+ ?: state.draft
+ )
+ }
+ }
+
+ fun save() {
+ val state = _uiState.value
+ if (!state.canSave) return
+ val target = state.draft
+
+ viewModelScope.launch {
+ AppContainer.updateSettingsUseCase { _ -> target }
+ .onSuccess {
+ _uiState.update {
+ it.copy(error = null, savedAt = System.currentTimeMillis())
+ }
+ }
+ .onFailure { error ->
+ _uiState.update {
+ it.copy(error = error.message ?: "Failed to save settings")
+ }
+ }
+ }
+ }
+
+ fun discardChanges() {
+ _uiState.update { state ->
+ state.copy(
+ draft = state.persisted,
+ portInput = state.persisted.defaultPort.toString(),
+ pollingInput = state.persisted.devicePollingIntervalSeconds.toString(),
+ error = null
+ )
+ }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(error = null) }
+ }
+
+ fun onCleared() {
+ viewModelScope.cancel()
+ }
+}
From 0c8a8d611a7e95511be406c81eb6135a279254b7 Mon Sep 17 00:00:00 2001
From: lucky
Date: Thu, 14 May 2026 19:48:12 +0900
Subject: [PATCH 2/2] docs+chore: refresh README and migrate deprecated Compose
APIs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
README:
- Features section now reflects every screen actually shipped (Verification Deep
Dive, Local Hosting, Intent Sniffer, Topology Map, Scheme Collision, Batch Test,
Logcat Streaming, QR, Favorites, Settings, keyboard shortcuts)
- Roadmap split into Done / Next / Later. Next items link to follow-up issues
(#29 tunnel, #30 iOS, #31 CLI, #32 APK)
- Tech stack and project structure updated; Compose hot-reload command added
Compose deprecated migration (11 sites):
- Modifier.menuAnchor() -> .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
All call sites were read-only OutlinedTextField inside ExposedDropdownMenuBox
- TabRow(...) -> PrimaryTabRow(...) (Diagnostics, Manifest, Sniffer)
- Icons.Default.Send -> Icons.AutoMirrored.Filled.Send
Remaining warnings deferred (different scope):
- AdbBinaryManager URL(String) constructor — Java's URL deprecation, network
layer change; touching it risks breaking ADB auto-download
- QrCodeDialog LocalClipboardManager — Compose's new LocalClipboard API is
suspend-based; converting requires reworking the copy flow with a coroutine
scope. Will track separately if it actually breaks in a future Compose release.
Completes #33.
---
README.md | 187 +++++++++++-------
.../ui/screen/batchtest/BatchTestScreen.kt | 2 +-
.../screen/diagnostics/DiagnosticsScreen.kt | 2 +-
.../diagnostics/VerificationDeepDiveScreen.kt | 2 +-
.../screen/localhosting/LocalHostingScreen.kt | 2 +-
.../ui/screen/logstream/LogStreamScreen.kt | 4 +-
.../screen/manifest/ManifestAnalyzerScreen.kt | 8 +-
.../ui/screen/sniffer/IntentSnifferScreen.kt | 4 +-
8 files changed, 128 insertions(+), 83 deletions(-)
diff --git a/README.md b/README.md
index d23a1e6..9842f40 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Android Deep Link Debugging & Orchestration Tool
- A powerful desktop application for debugging Android App Links and Deep Links
+ A desktop application for debugging Android App Links and Deep Links across devices, manifests, and on-device verification state.
@@ -20,138 +20,183 @@
## Features
+### Device Management
+- Auto-detect connected Android devices with OS version and SDK level
+- Concurrent multi-device support with per-device serial routing
+- Configurable polling cadence via **Settings**
+
### Manifest Analyzer
-- View all deep links configured in any installed app
-- Domain verification status (`verified`, `none`, etc.)
-- Test deep links directly on device with one click
-- Filter by App Links, Custom Schemes, or HTTP links
+- List installed packages and inspect every `` (App Links, Custom Schemes, HTTP)
+- Fire deep links straight to the device with one click and watch the response in the integrated log panel
+- Export captured manifest data for sharing
### Diagnostics
-- Validate `assetlinks.json` configuration
-- Check domain ownership setup
-- Identify common App Links issues
+- Validate `assetlinks.json` hosted at `https:///.well-known/assetlinks.json`
+- Surface common failure modes — missing file, invalid JSON, redirects, wrong Content-Type, network errors
+- Inline guidance for fixing each issue
-### Device Management
-- Auto-detect connected Android devices
-- Support for multiple devices simultaneously
-- Real-time device status monitoring
+### Verification Deep Dive
+- Per-domain App Links verification state (`verified`, `legacy_failure`, `state_no_response`, ...)
+- SHA-256 certificate fingerprint comparison between device and `assetlinks.json`
+- Root-cause analyzer with actionable suggestions
-## Screenshots
+### Local Hosting Simulation
+- One-click Ktor server (bound to `127.0.0.1`) that serves a generated `assetlinks.json`
+- Extract the signing fingerprint from a connected device
+- End-to-end workflow: start server → trigger `pm verify-app-links --re-verify` → poll for state changes → tear down
-### Dashboard
-Connect devices and monitor App Links verification status at a glance.
+### Intent Payload Sniffer
+- Capture Bundle data from intents fired on a device
+- Compare payload snapshots side-by-side to diff what changed between runs
-
+### Deep Link Topology Map
+- Interactive tree visualization of an app's URI structure (scheme → host → path)
-### Manifest Analyzer
-Analyze deep links from any installed app with domain verification status.
+### Scheme Collision Detector
+- Find packages competing for the same custom scheme so chooser dialogs can be debugged
+
+### Batch Deep Link Testing
+- Run a scenario file containing many URIs against a device
+- Parameter substitution for templated URIs (`{{userId}}`, etc.)
+- Import/export scenarios as JSON
-
+### Real-time Logcat Streaming
+- Stream filtered logcat for deep-link-related signals while testing
-### AssetLinks Diagnostics
-Validate your `assetlinks.json` configuration and identify issues.
+### Magic QR Code Generator
+- Generate a QR code for any deep link to test from a physical phone without typing
-
+### Deep Link Favorites
+- Save frequently used deep links and fire them with one click
+
+### Settings
+- Theme (Light / Dark / System) applied instantly
+- ADB binary override path
+- Default host:port for Local Hosting
+- Device detection polling interval
+
+### Keyboard Shortcuts
+- `Cmd/Ctrl + R` — refresh devices
+- `Cmd/Ctrl + F` — focus search
+- `Esc` — close panel
+- `?` — show all shortcuts
## Installation
### Prerequisites
- Java 17 or higher
-- ADB (Android Debug Bridge) installed and in PATH
-- Android device with USB debugging enabled
+- Android device with USB debugging enabled (or an emulator)
+- ADB is optional — LinkOps will download the official `platform-tools` automatically if it isn't already on your `PATH`
### From Source
```bash
-# Clone the repository
git clone https://github.com/manjees/link-ops.git
cd link-ops
-
-# Run the application
./gradlew :composeApp:run
```
+For faster iteration during UI work:
+
+```bash
+./gradlew :composeApp:runHot
+```
+
### Pre-built Binaries
-Coming soon! Check [Releases](https://github.com/manjees/link-ops/releases) for downloadable binaries.
+Coming soon — see [Releases](https://github.com/manjees/link-ops/releases).
## Usage
-### 1. Connect Your Device
-1. Enable USB debugging on your Android device
-2. Connect via USB cable
-3. LinkOps will automatically detect the device
+### 1. Connect a device
+Plug in your phone with USB debugging enabled. LinkOps polls ADB and surfaces the device on the Dashboard automatically.
+
+### 2. Inspect a package's deep links
+**Manifest** → pick the device → search for the package → review every intent-filter with its verification status. Use **Test** to fire a deep link.
-### 2. Analyze Deep Links
-1. Go to **Manifest** tab
-2. Select your device from dropdown
-3. Search and select an app package
-4. View all deep links with verification status
-5. Click **Test** to fire the deep link on device
+### 3. Validate a domain's setup
+**Diagnostics** → enter your domain. LinkOps fetches `assetlinks.json` and tells you exactly what's wrong.
-### 3. Validate AssetLinks
-1. Go to **Diagnostics** tab
-2. Enter your domain (e.g., `example.com`)
-3. View validation results and any issues
+### 4. Investigate a verification failure
+**Deep Dive** → pick the package. You'll see the device-reported state alongside the remote `assetlinks.json` and a fingerprint match check. The analyzer suggests fixes for each failure mode.
+
+### 5. Pre-flight before publishing the real `assetlinks.json`
+**Local Host** → extract the fingerprint from the device, preview the generated JSON, start the server, run the verification workflow. The Ktor server is `127.0.0.1`-only (see [#29](https://github.com/manjees/link-ops/issues/29) for the planned external-tunnel option that enables full on-device verification).
## Tech Stack
-- **Kotlin Multiplatform** - Cross-platform development
-- **Compose Desktop** - Modern declarative UI
-- **Clean Architecture** - Maintainable codebase
-- **Coroutines & Flow** - Reactive programming
+- **Kotlin Multiplatform** (JVM target) — single codebase, native desktop
+- **Compose Multiplatform Desktop** + **Material3** — declarative UI
+- **Clean Architecture** — `data` / `domain` / `ui` / `infrastructure` / `di`
+- **Ktor** — HTTP client (`assetlinks.json` fetch) + embedded server (Local Hosting)
+- **kotlinx.coroutines** — structured concurrency, `StateFlow` everywhere
+- **kotlinx.serialization** — settings, favorites, scenario import/export
+- **ZXing** — QR code generation
## Project Structure
```
composeApp/src/jvmMain/kotlin/com/manjee/linkops/
-├── data/ # Data layer (repositories, parsers)
-├── domain/ # Business logic (models, use cases)
-├── infrastructure/ # External services (ADB, network)
-├── ui/ # Presentation layer (screens, components)
-└── di/ # Dependency injection
+├── data/ # Repositories (impl), parsers, mappers, generators, analyzers, strategies
+├── domain/ # Models, repository interfaces, use cases (no Android/JVM specifics)
+├── infrastructure/ # ADB, network (Ktor client), server (Ktor server), QR
+├── ui/ # Compose screens, reusable components, theme, navigation
+└── di/ # AppContainer — manual wiring
```
+Dependency direction is strictly `ui → domain ← data ← infrastructure`. All ADB calls go through `AdbShellExecutor`, all HTTP through `AssetLinksClient`, all local serving through `AssetLinksServer`.
+
## Contributing
-We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
+Pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
-### Quick Start for Contributors
+### Quick Start
```bash
-# Fork and clone the repo
git clone https://github.com/YOUR_USERNAME/link-ops.git
-
-# Create a feature branch
-git checkout -b feature/amazing-feature
-
-# Make your changes and test
+git checkout -b feature/your-feature
./gradlew :composeApp:jvmTest
-
-# Commit and push
-git commit -m "Add amazing feature"
-git push origin feature/amazing-feature
-
-# Open a Pull Request
+git push origin feature/your-feature
```
## Roadmap
-- [ ] Intent Builder - Create custom intents
-- [ ] QR Code generation for deep links
-- [ ] History & Favorites
-- [ ] Export reports
-- [ ] Multi-language support
+### Done
+- [x] Manifest Analyzer
+- [x] AssetLinks Diagnostics
+- [x] Verification Deep Dive (fingerprint comparison + root cause)
+- [x] Local Hosting Simulation
+- [x] Intent Payload Sniffer
+- [x] Deep Link Topology Map
+- [x] Scheme Collision Detector
+- [x] Batch Deep Link Testing
+- [x] Real-time Logcat Streaming
+- [x] Magic QR Code Generator
+- [x] Deep Link Favorites
+- [x] Settings (theme, ADB override, hosting defaults, polling)
+- [x] Keyboard Shortcuts
+
+### Next
+- [ ] External HTTPS tunnel for Local Hosting ([#29](https://github.com/manjees/link-ops/issues/29)) — real on-device verification via ngrok / cloudflared
+- [ ] iOS Universal Links support ([#30](https://github.com/manjees/link-ops/issues/30)) — Android/iOS pair debugging
+- [ ] CLI / Headless mode + GitHub Action ([#31](https://github.com/manjees/link-ops/issues/31)) — CI/CD integration
+- [ ] APK file analysis ([#32](https://github.com/manjees/link-ops/issues/32)) — verify builds without installing
+
+### Later
+- [ ] Multi-language UI (i18n)
+- [ ] Pre-launch checklist
+- [ ] Emulator matrix automation (Android 11–15)
## License
-This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
+This project is licensed under the Apache License 2.0 — see the [LICENSE](LICENSE) file for details.
## Acknowledgments
- [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html)
- [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/)
- [Ktor](https://ktor.io/)
+- [ZXing](https://github.com/zxing/zxing)
---
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt
index 4f74f27..8b647d7 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/batchtest/BatchTestScreen.kt
@@ -325,7 +325,7 @@ private fun DeviceSelector(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
- .menuAnchor()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt
index a21e8ec..e34c77b 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt
@@ -46,7 +46,7 @@ fun DiagnosticsScreen(
.background(MaterialTheme.colorScheme.background)
) {
// Tab row
- TabRow(
+ PrimaryTabRow(
selectedTabIndex = uiState.activeTab.ordinal,
containerColor = MaterialTheme.colorScheme.surface
) {
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 baba98b..f97bee8 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
@@ -146,7 +146,7 @@ private fun InputSection(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
- .menuAnchor()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingScreen.kt
index 3c7c18d..d119687 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingScreen.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/localhosting/LocalHostingScreen.kt
@@ -320,7 +320,7 @@ private fun FingerprintSection(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
- .menuAnchor()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
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
index 2d26b64..ccc108e 100644
--- 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
@@ -233,7 +233,7 @@ private fun DeviceSelectionSection(
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
- .menuAnchor()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
singleLine = true
)
@@ -456,7 +456,7 @@ private fun SearchFilterSection(
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = levelExpanded) },
modifier = Modifier
- .menuAnchor()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
singleLine = true
)
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt
index a92b754..1730f23 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt
@@ -15,7 +15,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.QrCode2
-import androidx.compose.material.icons.filled.Send
+import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.material3.*
@@ -172,7 +172,7 @@ fun ManifestAnalyzerScreen(
// Tab row for switching views
if (uiState.analysisResult?.manifestInfo != null) {
- TabRow(
+ PrimaryTabRow(
selectedTabIndex = uiState.selectedTab.ordinal,
modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -305,7 +305,7 @@ private fun DeviceSelector(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
- .menuAnchor()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
@@ -1129,7 +1129,7 @@ private fun DeepLinkItem(
modifier = Modifier.height(28.dp)
) {
Icon(
- Icons.Default.Send,
+ Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(14.dp)
)
diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferScreen.kt
index 68795d7..3c4cac9 100644
--- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferScreen.kt
+++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/sniffer/IntentSnifferScreen.kt
@@ -258,7 +258,7 @@ private fun DeviceSelector(
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
- modifier = Modifier.menuAnchor().fillMaxWidth(),
+ modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
singleLine = true,
label = { Text("Device") }
)
@@ -370,7 +370,7 @@ private fun ContentPanel(
.background(MaterialTheme.colorScheme.background)
) {
// Tab Row
- TabRow(
+ PrimaryTabRow(
selectedTabIndex = uiState.selectedTab.ordinal,
containerColor = MaterialTheme.colorScheme.surface
) {