mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
feat(app): shell-only debug launch switches for onboarding and the trust dialog (#7375)
This commit is contained in:
1 parent
acf608f2e1
commit
640c1ab8c1
11 files changed
+293
-45
No files matched your search
@@ -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`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
.cxx
|
||||
/app/release
|
||||
**/debug/**
|
||||
!/androidApp/src/debug/**
|
||||
**/release/**
|
||||
|
||||
# Java KeyStore certificates
|
||||
|
||||
@@ -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 <pkg> android.permission.BLUETOOTH_SCAN
|
||||
adb shell pm grant <pkg> android.permission.BLUETOOTH_CONNECT
|
||||
adb shell pm grant <pkg> android.permission.ACCESS_FINE_LOCATION
|
||||
adb shell pm grant <pkg> android.permission.POST_NOTIFICATIONS # API 33+
|
||||
adb shell am start -n <pkg>/org.meshtastic.app.MainActivity --ez skip_onboarding true
|
||||
adb shell am start -n <pkg>/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.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<!--
|
||||
The automation entry point: MainActivity honours its launch switches only when started through this alias.
|
||||
DUMP is held by the shell and never by an installed app, so `adb shell am start` and UiAutomation can use it
|
||||
and nothing else on the device can.
|
||||
-->
|
||||
<activity-alias
|
||||
android:name="org.meshtastic.app.AutomationLauncher"
|
||||
android:targetActivity="org.meshtastic.app.MainActivity"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:exported="true" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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<IntroViewModel>()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -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<String>) {
|
||||
Logger.setLogWriters(listOf(platformLogWriter(), InMemoryLogBuffer))
|
||||
Logger.i { "Meshtastic Desktop — Starting" }
|
||||
startKoin<DesktopKoinApp> {}
|
||||
.also { app ->
|
||||
if (DesktopBuildConfig.IS_DEBUG && SKIP_CONNECT_CONFIRM_ARG in args) {
|
||||
app.koin.get<LaunchOptions>().skipDeepLinkConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { publishExitApplication(::exitApplication) }
|
||||
val systemLocale = remember { Locale.getDefault() }
|
||||
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
|
||||
val httpClient = remember { koinApp.koin.get<HttpClient>() }
|
||||
|
||||
DeepLinkHandler(args, uiViewModel)
|
||||
DeepLinkHandler(args, uiViewModel, remember { koinApp.koin.get<LaunchOptions>() })
|
||||
MeshServiceLifecycle()
|
||||
ThemeAndLocaleProvider(uiViewModel)
|
||||
}
|
||||
@@ -189,7 +198,11 @@ fun main(args: Array<String>) {
|
||||
|
||||
/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */
|
||||
@Composable
|
||||
private fun ApplicationScope.DeepLinkHandler(args: Array<String>, uiViewModel: UIViewModel) {
|
||||
private fun ApplicationScope.DeepLinkHandler(
|
||||
args: Array<String>,
|
||||
uiViewModel: UIViewModel,
|
||||
launchOptions: LaunchOptions,
|
||||
) {
|
||||
LaunchedEffect(args) {
|
||||
args.forEach { arg ->
|
||||
if (
|
||||
@@ -207,6 +220,8 @@ private fun ApplicationScope.DeepLinkHandler(args: Array<String>, 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" } }
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+52
-17
@@ -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<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
|
||||
|
||||
// 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<LaunchOptions>()
|
||||
|
||||
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<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+92
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user