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 -![Dashboard](docs/images/dashboard.png) +### 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 -![Manifest Analyzer](docs/images/manifest-analyzer.png) +### 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 -![Diagnostics](docs/images/diagnostics.png) +### 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 ) {