From 640c1ab8c178763d38f6b82aedd8e4400a5fb057 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:40:54 +0000 Subject: [PATCH] feat(app): shell-only debug launch switches for onboarding and the trust dialog (#7375) --- .../skills/run-meshtastic-android/SKILL.md | 13 ++- .../run-meshtastic-android/driver_emulator.py | 48 +++++++--- .gitignore | 1 + .skills/testing-ci/SKILL.md | 4 +- androidApp/src/debug/AndroidManifest.xml | 34 +++++++ .../kotlin/org/meshtastic/app/MainActivity.kt | 21 ++++- .../core/common/state/LaunchOptions.kt | 33 +++++++ .../kotlin/org/meshtastic/desktop/Main.kt | 19 +++- .../en/developer/navigation-and-deep-links.md | 4 +- .../navigation/ConnectionsNavigation.kt | 69 ++++++++++---- .../navigation/DeepLinkConnectPromptTest.kt | 92 +++++++++++++++++++ 11 files changed, 293 insertions(+), 45 deletions(-) create mode 100644 androidApp/src/debug/AndroidManifest.xml create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/LaunchOptions.kt create mode 100644 feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/navigation/DeepLinkConnectPromptTest.kt diff --git a/.claude/skills/run-meshtastic-android/SKILL.md b/.claude/skills/run-meshtastic-android/SKILL.md index 35c41a0c23..cbb4748d20 100644 --- a/.claude/skills/run-meshtastic-android/SKILL.md +++ b/.claude/skills/run-meshtastic-android/SKILL.md @@ -123,11 +123,14 @@ python3 .claude/skills/run-meshtastic-android/driver_emulator.py -s emulator-555 connect=t10.0.2.2:4404 wait_text=RPLY ss=/tmp/emu.png ``` -`connect` force-stops the app, relaunches `org.meshtastic.app.MainActivity` with the -debug-only `skip_onboarding` extra and the `/connections?address=` deeplink -(`t` = TCP, `x` = BLE, `s` = serial, `n` = disconnect — full path list in -`docs/en/developer/navigation-and-deep-links.md`), then waits for the trust dialog -newer builds pop and taps its **Connect** button. Success looks like the Connection +`connect` force-stops the app, relaunches it through `org.meshtastic.app.AutomationLauncher` +with the `skip_onboarding` and `skip_connect_confirm` extras and the +`/connections?address=` deeplink (`t` = TCP, `x` = BLE, `s` = serial, `n` = disconnect — +full path list in `docs/en/developer/navigation-and-deep-links.md`). That alias exists +only in debug builds and only the shell may start it, and the extras do nothing on any +other launch. With `skip_connect_confirm` the address is applied with no trust dialog; a +build that predates the alias still pops it, and the driver taps its **Connect** button. Desktop +takes the same switch as a `--skip-connect-confirm` argument in a non-release build. Success looks like the Connection screen showing `RPLY Replay Observer` with a **Disconnect** button, and `replay_status` reporting `connected:true`. diff --git a/.claude/skills/run-meshtastic-android/driver_emulator.py b/.claude/skills/run-meshtastic-android/driver_emulator.py index 6802eae681..bedf53ab01 100644 --- a/.claude/skills/run-meshtastic-android/driver_emulator.py +++ b/.claude/skills/run-meshtastic-android/driver_emulator.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Drive the Meshtastic Android app on an emulator/device over adb. -Scripted bring-up (never hand-walk onboarding): launches MainActivity with the -debug-only skip_onboarding extra and a /connections deeplink that auto-connects -to a TCP radio — pair it with a replay-sim radio (an AVD reaches the host at +Scripted bring-up (never hand-walk onboarding): launches the debug build's +shell-only AutomationLauncher alias with the skip_onboarding extra and a +/connections deeplink that auto-connects to a TCP radio — pair it with a +replay-sim radio (an AVD reaches the host at 10.0.2.2). Handles the trust dialog newer builds pop on first connect. Usage: @@ -38,18 +39,30 @@ import xml.etree.ElementTree as ET SERIAL = None PKG = "com.geeksville.mesh.fdroid.debug" -ACTIVITY = "org.meshtastic.app.MainActivity" +ACTIVITY = "org.meshtastic.app.AutomationLauncher" +# Builds without the alias; they ignore the launch switches. +FALLBACK_ACTIVITY = "org.meshtastic.app.MainActivity" def adb(*args): cmd = ["adb"] + (["-s", SERIAL] if SERIAL else []) + list(args) r = subprocess.run(cmd, capture_output=True, timeout=120) if r.returncode != 0: - err = (r.stderr or b"").decode(errors="replace").strip() + # am start reports a missing component on stdout + err = ((r.stderr or b"") + (r.stdout or b"")).decode(errors="replace").strip() raise RuntimeError(f"adb {' '.join(args)} failed ({r.returncode}): {err[:300]}") return (r.stdout or b"").decode(errors="replace") +def start_app(*extras): + try: + return adb("shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}", *extras) + except RuntimeError as e: + if "does not exist" not in str(e): + raise + return adb("shell", "am", "start", "-n", f"{PKG}/{FALLBACK_ACTIVITY}", *extras) + + def ui_dump(): adb("shell", "uiautomator", "dump", "/sdcard/ui.xml") return adb("shell", "cat", "/sdcard/ui.xml") @@ -118,19 +131,26 @@ def wait_text(text, timeout=60): def connect(addr): adb("shell", "am", "force-stop", PKG) time.sleep(1) - adb( - "shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}", + start_app( "--ez", "skip_onboarding", "true", + "--ez", "skip_connect_confirm", "true", "-a", "android.intent.action.VIEW", "-d", f"https://meshtastic.org/connections?address={addr}", ) - # Builds >2.8.1 pop a trust dialog on first connect to a new device. Match its - # title, not bare "Connect" — that substring also matches "Stop Connecting". - r = wait_text("Connect to this device", timeout=30) - if r.startswith("found"): - print(tap_text("Connect")) + # Debug builds with the AutomationLauncher alias apply the address with no dialog; + # older ones pop the trust dialog, sometimes late on a slow emulator. + # Watch for either for 30 s. Match the dialog's title, not bare "Connect", which + # also matches "Stop Connecting". + deadline = time.time() + 30 + while time.time() < deadline: + if any(True for _ in find("Disconnect")): + break + if any(True for _ in find("Connect to this device")): + print(tap_text("Connect")) + break + time.sleep(3) else: - print("no trust dialog seen — verifying the connection directly") + print("neither the trust dialog nor a connection appeared in 30 s") # A missing dialog does not prove success (the launch or deeplink may have failed): # require the Connection screen's Disconnect button before claiming victory. v = wait_text("Disconnect", timeout=60) @@ -162,7 +182,7 @@ def main(): if res.startswith("FAILED"): return 1 elif name == "launch": - adb("shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}", "--ez", "skip_onboarding", "true") + start_app("--ez", "skip_onboarding", "true") print("launched") elif name == "stop": adb("shell", "am", "force-stop", PKG) diff --git a/.gitignore b/.gitignore index d02a8bbd3d..50a04d914f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ .cxx /app/release **/debug/** +!/androidApp/src/debug/** **/release/** # Java KeyStore certificates diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 2d3be10d40..e7570fb50f 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -84,14 +84,14 @@ Rendering is **host-deterministic** (layoutlib): a local `update` produces refer ## 3c) Fresh-install manual/agent testing: skip onboarding -Debug builds accept an intent extra to skip the intro flow (`MainActivity.kt`, `BuildConfig.DEBUG`-gated — never reaches release/Play builds). Pair with `pm grant` (native Android, no app code) to pre-accept runtime permissions: +Debug builds accept an intent extra to skip the intro flow, honoured only on a launch through the `AutomationLauncher` alias (`androidApp/src/debug/AndroidManifest.xml`), which requires `DUMP` so only the shell can start it. Release builds have no alias. Pair with `pm grant` (native Android, no app code) to pre-accept runtime permissions: ```bash adb shell pm grant android.permission.BLUETOOTH_SCAN adb shell pm grant android.permission.BLUETOOTH_CONNECT adb shell pm grant android.permission.ACCESS_FINE_LOCATION adb shell pm grant android.permission.POST_NOTIFICATIONS # API 33+ -adb shell am start -n /org.meshtastic.app.MainActivity --ez skip_onboarding true +adb shell am start -n /org.meshtastic.app.AutomationLauncher --ez skip_onboarding true ``` Use this whenever driving the app from a fresh install/uninstall (screenshot tests, UI automation, agent-driven exploration) instead of clicking through the intro screens. diff --git a/androidApp/src/debug/AndroidManifest.xml b/androidApp/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..f8c6eee080 --- /dev/null +++ b/androidApp/src/debug/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 4f599e76bb..02c783f202 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -60,6 +60,7 @@ import org.meshtastic.app.node.component.InlineMap import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner +import org.meshtastic.core.common.state.LaunchOptions import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.network.repository.UsbRepository @@ -117,15 +118,21 @@ class MainActivity : AppCompatActivity() { private val usbRepository: UsbRepository by inject() private val mapLayersManager: MapLayersManager by inject() + private val launchOptions: LaunchOptions by inject() override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) - if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SKIP_ONBOARDING, false)) { + // MainActivity is exported, so any app can send these extras; only the shell can start the debug alias. + val automationLaunch = BuildConfig.DEBUG && intent.component?.className == AUTOMATION_LAUNCHER + if (automationLaunch && intent.getBooleanExtra(EXTRA_SKIP_ONBOARDING, false)) { + launchOptions.skipOnboarding = true model.onAppIntroCompleted() } + launchOptions.skipDeepLinkConfirmation = + automationLaunch && intent.getBooleanExtra(EXTRA_SKIP_CONNECT_CONFIRM, false) enableEdgeToEdge() @@ -166,7 +173,7 @@ class MainActivity : AppCompatActivity() { // once we've decided whether to show the intro or the main screen. ReportDrawnWhen { true } - if (appIntroCompleted) { + if (appIntroCompleted || launchOptions.skipOnboarding) { MainScreen() } else { val introViewModel = koinViewModel() @@ -177,7 +184,11 @@ class MainActivity : AppCompatActivity() { } // Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent - addOnNewIntentListener { intent -> handleIntent(intent) } + addOnNewIntentListener { intent -> + // The switch covers the launch it came with; a link that arrives later still asks. + launchOptions.skipDeepLinkConfirmation = false + handleIntent(intent) + } handleIntent(intent) } @@ -415,6 +426,10 @@ class MainActivity : AppCompatActivity() { private companion object { const val EXTRA_SKIP_ONBOARDING = "skip_onboarding" + const val EXTRA_SKIP_CONNECT_CONFIRM = "skip_connect_confirm" + + /** The DUMP-guarded alias in the debug manifest. */ + const val AUTOMATION_LAUNCHER = "org.meshtastic.app.AutomationLauncher" } } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/LaunchOptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/LaunchOptions.kt new file mode 100644 index 0000000000..fbdfea4463 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/LaunchOptions.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.state + +import org.koin.core.annotation.Single + +/** + * Switches a debug build's entry point sets for automation such as the store-screenshot pipeline: Android from a launch + * through its shell-only automation alias, desktop from its command line. A deep link can never set one, because the + * checks they relax exist to stop links. + */ +@Single +class LaunchOptions { + /** Show the main screen without onboarding, before the persisted "intro completed" flag has loaded. */ + var skipOnboarding: Boolean = false + + /** Apply a `connections` deep link's address without asking the user to confirm it. */ + var skipDeepLinkConfirmation: Boolean = false +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index c23c87d83e..461eddee1c 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -78,6 +78,7 @@ import org.maplibre.compose.desktop.ProvideMapPresentationHost import org.maplibre.compose.desktop.rememberAwtComposeMapPresentationHost import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.log.InMemoryLogBuffer +import org.meshtastic.core.common.state.LaunchOptions import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.desktopDataDir import org.meshtastic.core.model.DeviceAddress @@ -127,6 +128,9 @@ import coil3.util.Logger as CoilLogger private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB +/** Debug builds only: apply a `connections` deep link from the command line without the trust dialog. */ +private const val SKIP_CONNECT_CONFIRM_ARG = "--skip-connect-confirm" + /** * Loads an SVG from JVM classpath resources and returns a [Painter]. * @@ -160,13 +164,18 @@ fun main(args: Array) { Logger.setLogWriters(listOf(platformLogWriter(), InMemoryLogBuffer)) Logger.i { "Meshtastic Desktop — Starting" } startKoin {} + .also { app -> + if (DesktopBuildConfig.IS_DEBUG && SKIP_CONNECT_CONFIRM_ARG in args) { + app.koin.get().skipDeepLinkConfirmation = true + } + } } LaunchedEffect(Unit) { publishExitApplication(::exitApplication) } val systemLocale = remember { Locale.getDefault() } val uiViewModel = remember { koinApp.koin.get() } val httpClient = remember { koinApp.koin.get() } - DeepLinkHandler(args, uiViewModel) + DeepLinkHandler(args, uiViewModel, remember { koinApp.koin.get() }) MeshServiceLifecycle() ThemeAndLocaleProvider(uiViewModel) } @@ -189,7 +198,11 @@ fun main(args: Array) { /** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */ @Composable -private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: UIViewModel) { +private fun ApplicationScope.DeepLinkHandler( + args: Array, + uiViewModel: UIViewModel, + launchOptions: LaunchOptions, +) { LaunchedEffect(args) { args.forEach { arg -> if ( @@ -207,6 +220,8 @@ private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: U LaunchedEffect(Unit) { if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) { Desktop.getDesktop().setOpenURIHandler { event -> + // The launch switch covers the links this process was started with, never one the OS hands over later. + launchOptions.skipDeepLinkConfirmation = false val uriStr = event.uri.toString() uiViewModel.handleDeepLink(CommonUri.parse(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } } diff --git a/docs/en/developer/navigation-and-deep-links.md b/docs/en/developer/navigation-and-deep-links.md index 28c4d47b19..c61c0d535b 100644 --- a/docs/en/developer/navigation-and-deep-links.md +++ b/docs/en/developer/navigation-and-deep-links.md @@ -87,8 +87,8 @@ manifest entry fails CI. | URI Path | Route | Notes | |----------|-------|-------| | `/connections` | `ConnectionsRoute.Connections(null)` | Connections screen | -| `/connections?address={prefixedAddress}` | `ConnectionsRoute.Connections(address)` | Auto-connects to a node without manual selection — the address uses the app's internal transport-prefixed format: `t192.168.1.1:4403` (TCP), `xAA:BB:CC:DD:EE:FF` (BLE), `s/dev/ttyUSB0` (serial). Intended for scripts/AI tooling driving the app. | -| `/connections?address=n` | `ConnectionsRoute.Connections("n")` | Disconnects the current node instead of connecting (`n` = the internal "no device selected" sentinel). | +| `/connections?address={prefixedAddress}` | `ConnectionsRoute.Connections(address)` | Connects to a node once the user confirms the "Connect to this device?" dialog — the address uses the app's internal transport-prefixed format: `t192.168.1.1:4403` (TCP), `xAA:BB:CC:DD:EE:FF` (BLE), `s/dev/ttyUSB0` (serial). Intended for scripts/AI tooling driving the app; a debug build started through its shell-only `org.meshtastic.app.AutomationLauncher` alias with the `skip_connect_confirm` intent extra, or with the `--skip-connect-confirm` argument on desktop, applies the address it was launched with without the dialog; links that arrive later still ask. | +| `/connections?address=n` | `ConnectionsRoute.Connections("n")` | Disconnects the current node instead of connecting (`n` = the internal "no device selected" sentinel), after the same confirmation. | | `/wifi-provision` | `WifiProvisionRoute.WifiProvision(null)` | Wi-Fi provisioning screen | | `/wifi-provision?address={mac}` | `WifiProvisionRoute.WifiProvision(mac)` | Provisioning targeting a specific node MAC | | `/settings` | `SettingsRoute.Settings(null)` | Settings root | diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index 9aba8061cb..9d8802398e 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -16,15 +16,20 @@ */ package org.meshtastic.feature.connections.navigation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.common.state.LaunchOptions import org.meshtastic.core.navigation.ConnectionsRoute import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.resources.Res @@ -47,27 +52,18 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) // A deep link (e.g. from AI/automation tooling) may name a device address, or `n` to disconnect. The // `connections` path is a verified https://meshtastic.org app link, so any web page can fire one at us — - // always confirm before re-pointing or dropping the radio connection. + // confirm before re-pointing or dropping the radio connection. Only a debug launch switch skips it. var pendingAddress by rememberSaveable(key.address) { mutableStateOf(key.address?.takeIf(String::isNotBlank)) } + val launchOptions = koinInject() pendingAddress?.let { address -> - val isDisconnect = address == NO_DEVICE_SELECTED - MeshtasticDialog( - titleRes = - if (isDisconnect) Res.string.deep_link_disconnect_title else Res.string.deep_link_connect_title, - message = - if (isDisconnect) { - stringResource(Res.string.deep_link_disconnect_message) - } else { - stringResource(Res.string.deep_link_connect_message, address) + DeepLinkConnectPrompt( + address = address, + skipConfirmation = launchOptions.skipDeepLinkConfirmation, + onApply = { + if (it == NO_DEVICE_SELECTED) scanModel.disconnect() else scanModel.changeDeviceAddress(it) }, - confirmTextRes = if (isDisconnect) Res.string.disconnect else Res.string.connect, - onConfirm = { - if (isDisconnect) scanModel.disconnect() else scanModel.changeDeviceAddress(address) - pendingAddress = null - }, - dismissTextRes = Res.string.cancel, - onDismiss = { pendingAddress = null }, + onDone = { pendingAddress = null }, ) } @@ -79,3 +75,42 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) ) } } + +/** + * The trust step for a `connections` deep link. The address is applied only once the user confirms, unless + * [skipConfirmation], a debug launch switch, says to apply it straight away. + */ +@Composable +internal fun DeepLinkConnectPrompt( + address: String, + skipConfirmation: Boolean, + onApply: (String) -> Unit, + onDone: () -> Unit, +) { + if (skipConfirmation) { + val apply by rememberUpdatedState(onApply) + val done by rememberUpdatedState(onDone) + LaunchedEffect(address) { + apply(address) + done() + } + } else { + val isDisconnect = address == NO_DEVICE_SELECTED + MeshtasticDialog( + titleRes = if (isDisconnect) Res.string.deep_link_disconnect_title else Res.string.deep_link_connect_title, + message = + if (isDisconnect) { + stringResource(Res.string.deep_link_disconnect_message) + } else { + stringResource(Res.string.deep_link_connect_message, address) + }, + confirmTextRes = if (isDisconnect) Res.string.disconnect else Res.string.connect, + onConfirm = { + onApply(address) + onDone() + }, + dismissTextRes = Res.string.cancel, + onDismiss = onDone, + ) + } +} diff --git a/feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/navigation/DeepLinkConnectPromptTest.kt b/feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/navigation/DeepLinkConnectPromptTest.kt new file mode 100644 index 0000000000..70e91ff73f --- /dev/null +++ b/feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/navigation/DeepLinkConnectPromptTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.connections.navigation + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.connect +import org.meshtastic.core.resources.deep_link_connect_title +import org.meshtastic.core.resources.getString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class DeepLinkConnectPromptTest { + + private val applied = mutableListOf() + private var done = 0 + + @Test + fun `a deep link address waits for the user to confirm`() = runComposeUiTest { + setPrompt(skipConfirmation = false) + + onNodeWithText(getString(Res.string.deep_link_connect_title)).assertIsDisplayed() + assertTrue(applied.isEmpty()) + + onNodeWithText(getString(Res.string.connect)).performClick() + waitForIdle() + + assertEquals(listOf(ADDRESS), applied) + assertEquals(1, done) + } + + @Test + fun `cancelling the prompt applies nothing`() = runComposeUiTest { + setPrompt(skipConfirmation = false) + + onNodeWithText(getString(Res.string.cancel)).performClick() + waitForIdle() + + assertTrue(applied.isEmpty()) + assertEquals(1, done) + } + + @Test + fun `the launch switch applies the address once with no dialog`() = runComposeUiTest { + setPrompt(skipConfirmation = true) + waitForIdle() + + onNodeWithText(getString(Res.string.deep_link_connect_title)).assertDoesNotExist() + assertEquals(listOf(ADDRESS), applied) + assertEquals(1, done) + } + + private fun ComposeUiTest.setPrompt(skipConfirmation: Boolean) { + setContent { + MaterialTheme { + DeepLinkConnectPrompt( + address = ADDRESS, + skipConfirmation = skipConfirmation, + onApply = { applied += it }, + onDone = { done++ }, + ) + } + } + } + + private companion object { + const val ADDRESS = "t10.0.2.2:4403" + } +}