feat(app): shell-only debug launch switches for onboarding and the trust dialog (#7375)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-26 17:40:54 +00:00
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)
+1
View File
@@ -15,6 +15,7 @@
.cxx
/app/release
**/debug/**
!/androidApp/src/debug/**
**/release/**
# Java KeyStore certificates
+2 -2
View File
@@ -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.
+34
View File
@@ -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 |
@@ -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,
)
}
}
@@ -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"
}
}