mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-03 00:54:34 -04:00
feat(network): a hidden showcase scenario for Demo Mode (#7377)
This commit is contained in:
1 parent
edb37d8c35
commit
cc8c8789c6
5 files changed
+559
-248
No files matched your search
+42
-246
@@ -39,7 +39,6 @@ import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.Neighbor
|
||||
@@ -54,7 +53,6 @@ import org.meshtastic.proto.ToRadio
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.proto.Channel as ProtoChannel
|
||||
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
private val defaultLoRaConfig =
|
||||
Config.LoRaConfig.Builder()
|
||||
@@ -99,6 +97,9 @@ class MockRadioTransport(
|
||||
val address: String,
|
||||
) : RadioTransport {
|
||||
|
||||
/** The mesh this transport plays, picked by the address suffix; see [MockScenario.forAddress]. */
|
||||
private val scenario = MockScenario.forAddress(address)
|
||||
|
||||
/**
|
||||
* Hands out packet ids.
|
||||
*
|
||||
@@ -219,7 +220,7 @@ class MockRadioTransport(
|
||||
.also { wb ->
|
||||
wb.statusmessage =
|
||||
ModuleConfig.StatusMessageConfig.Builder()
|
||||
.also { wb -> wb.node_status = MY_NODE_STATUS }
|
||||
.also { wb -> wb.node_status = scenario.nodeStatus }
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
@@ -248,8 +249,8 @@ class MockRadioTransport(
|
||||
val metadata =
|
||||
DeviceMetadata.Builder()
|
||||
.also { wb ->
|
||||
wb.firmware_version = FIRMWARE_VERSION
|
||||
wb.hw_model = HardwareModel.ANDROID_SIM
|
||||
wb.firmware_version = scenario.firmwareVersion
|
||||
wb.hw_model = scenario.hwModel
|
||||
}
|
||||
.build()
|
||||
val frames =
|
||||
@@ -259,7 +260,7 @@ class MockRadioTransport(
|
||||
wb.my_info =
|
||||
ProtoMyNodeInfo.Builder()
|
||||
.also { wb ->
|
||||
wb.my_node_num = MY_NODE
|
||||
wb.my_node_num = scenario.myNode
|
||||
wb.reboot_count = 3
|
||||
}
|
||||
.build()
|
||||
@@ -307,9 +308,9 @@ class MockRadioTransport(
|
||||
|
||||
/** Stage 2: the node database. This is the only window in which the app accepts `node_info`. */
|
||||
private fun sendNodeInfoStage() {
|
||||
Logger.d { "Mock transport answering node-info stage with ${SIM_PEERS.size + 1} nodes" }
|
||||
Logger.d { "Mock transport answering node-info stage with ${scenario.peers.size + 1} nodes" }
|
||||
callback.handleFromRadio(FromRadio.Builder().also { wb -> wb.node_info = localNodeInfo() }.build().encode())
|
||||
SIM_PEERS.forEach { peer ->
|
||||
scenario.peers.forEach { peer ->
|
||||
callback.handleFromRadio(
|
||||
FromRadio.Builder().also { wb -> wb.node_info = peer.toNodeInfo() }.build().encode(),
|
||||
)
|
||||
@@ -328,19 +329,19 @@ class MockRadioTransport(
|
||||
|
||||
private fun localNodeInfo() = NodeInfo.Builder()
|
||||
.also { wb ->
|
||||
wb.num = MY_NODE
|
||||
wb.num = scenario.myNode
|
||||
wb.last_heard = nowSeconds.toInt()
|
||||
wb.user =
|
||||
User.Builder()
|
||||
.also { wb ->
|
||||
wb.id = NodeAddress.numToDefaultId(MY_NODE)
|
||||
wb.long_name = "Demo Handset"
|
||||
wb.short_name = "DEMO"
|
||||
wb.hw_model = HardwareModel.ANDROID_SIM
|
||||
wb.id = NodeAddress.numToDefaultId(scenario.myNode)
|
||||
wb.long_name = scenario.longName
|
||||
wb.short_name = scenario.shortName
|
||||
wb.hw_model = scenario.hwModel
|
||||
wb.role = Config.DeviceConfig.Role.CLIENT
|
||||
}
|
||||
.build()
|
||||
wb.position = MY_POSITION.toProto()
|
||||
wb.position = scenario.position.toProto()
|
||||
wb.device_metrics =
|
||||
DeviceMetrics.Builder()
|
||||
.also { wb ->
|
||||
@@ -397,35 +398,35 @@ class MockRadioTransport(
|
||||
* points and messages.
|
||||
*/
|
||||
private suspend fun seedTraffic() {
|
||||
SIM_PEERS.forEach { peer ->
|
||||
scenario.peers.forEach { peer ->
|
||||
lifecycle.runIfOpen { callback.handleFromRadio(peer.positionPacket(nextPacketId()).encode()) }
|
||||
delay(SEED_SPACING_MS)
|
||||
}
|
||||
|
||||
SIM_PEERS.take(TELEMETRY_PEER_COUNT).forEach { peer ->
|
||||
scenario.peers.take(scenario.telemetryPeerCount).forEach { peer ->
|
||||
lifecycle.runIfOpen {
|
||||
callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick = 0).encode())
|
||||
}
|
||||
delay(SEED_SPACING_MS)
|
||||
}
|
||||
|
||||
WEATHER_PEER_INDEXES.forEach { index ->
|
||||
val peer = SIM_PEERS[index]
|
||||
scenario.weatherPeerIndexes.forEach { index ->
|
||||
val peer = scenario.peers[index]
|
||||
lifecycle.runIfOpen {
|
||||
callback.handleFromRadio(peer.environmentTelemetryPacket(nextPacketId(), tick = 0).encode())
|
||||
}
|
||||
delay(SEED_SPACING_MS)
|
||||
}
|
||||
|
||||
lifecycle.runIfOpen { callback.handleFromRadio(SIM_PEERS[0].neighborInfoPacket(nextPacketId()).encode()) }
|
||||
lifecycle.runIfOpen { callback.handleFromRadio(scenario.peers[0].neighborInfoPacket(nextPacketId()).encode()) }
|
||||
delay(SEED_SPACING_MS)
|
||||
lifecycle.runIfOpen { callback.handleFromRadio(SIM_PEERS[1].nodeStatusPacket(nextPacketId()).encode()) }
|
||||
lifecycle.runIfOpen { callback.handleFromRadio(scenario.peers[1].nodeStatusPacket(nextPacketId()).encode()) }
|
||||
delay(SEED_SPACING_MS)
|
||||
|
||||
// Each message is stamped progressively closer to now, so the thread reads as a conversation that unfolded over
|
||||
// the last while rather than a block of messages that all arrived in the same second.
|
||||
CHANNEL_CONVERSATION.forEachIndexed { index, (peerIndex, text) ->
|
||||
val peer = SIM_PEERS[peerIndex]
|
||||
scenario.channelConversation.forEachIndexed { index, (peerIndex, text) ->
|
||||
val peer = scenario.peers[peerIndex]
|
||||
lifecycle.runIfOpen {
|
||||
callback.handleFromRadio(
|
||||
peer
|
||||
@@ -433,7 +434,7 @@ class MockRadioTransport(
|
||||
id = nextPacketId(),
|
||||
to = BROADCAST_ADDR,
|
||||
text = text,
|
||||
ageSeconds = messageAgeSeconds(CHANNEL_CONVERSATION.size, index),
|
||||
ageSeconds = messageAgeSeconds(scenario.channelConversation.size, index),
|
||||
)
|
||||
.encode(),
|
||||
)
|
||||
@@ -441,22 +442,23 @@ class MockRadioTransport(
|
||||
delay(SEED_SPACING_MS)
|
||||
}
|
||||
|
||||
DIRECT_CONVERSATION.forEachIndexed { index, text ->
|
||||
scenario.directConversation.forEachIndexed { index, text ->
|
||||
lifecycle.runIfOpen {
|
||||
callback.handleFromRadio(
|
||||
SIM_PEERS[DIRECT_PEER_INDEX].textPacket(
|
||||
id = nextPacketId(),
|
||||
to = MY_NODE,
|
||||
text = text,
|
||||
ageSeconds = messageAgeSeconds(DIRECT_CONVERSATION.size, index),
|
||||
)
|
||||
scenario.peers[scenario.directPeerIndex]
|
||||
.textPacket(
|
||||
id = nextPacketId(),
|
||||
to = scenario.myNode,
|
||||
text = text,
|
||||
ageSeconds = messageAgeSeconds(scenario.directConversation.size, index),
|
||||
)
|
||||
.encode(),
|
||||
)
|
||||
}
|
||||
delay(SEED_SPACING_MS)
|
||||
}
|
||||
|
||||
streamLiveTelemetry()
|
||||
if (scenario.liveTelemetry) streamLiveTelemetry()
|
||||
}
|
||||
|
||||
/** How long ago the message at [index] of a [count]-message seeded thread was "received". Oldest first. */
|
||||
@@ -470,10 +472,10 @@ class MockRadioTransport(
|
||||
var tick = 1
|
||||
while (true) {
|
||||
delay(LIVE_TICK_MS)
|
||||
val peer = SIM_PEERS[tick % TELEMETRY_PEER_COUNT]
|
||||
val peer = scenario.peers[tick % scenario.telemetryPeerCount]
|
||||
lifecycle.runIfOpen { callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick).encode()) }
|
||||
if (tick % WEATHER_TICK_INTERVAL == 0) {
|
||||
val weatherPeer = SIM_PEERS[WEATHER_PEER_INDEXES.first()]
|
||||
val weatherPeer = scenario.peers[scenario.weatherPeerIndexes.first()]
|
||||
lifecycle.runIfOpen {
|
||||
callback.handleFromRadio(weatherPeer.environmentTelemetryPacket(nextPacketId(), tick).encode())
|
||||
}
|
||||
@@ -491,11 +493,11 @@ class MockRadioTransport(
|
||||
val isBroadcast = packet.to == BROADCAST_ADDR
|
||||
val responder =
|
||||
if (isBroadcast) {
|
||||
SIM_PEERS[DIRECT_PEER_INDEX]
|
||||
scenario.peers[scenario.directPeerIndex]
|
||||
} else {
|
||||
SIM_PEERS.firstOrNull { it.num == packet.to } ?: return
|
||||
scenario.peers.firstOrNull { it.num == packet.to } ?: return
|
||||
}
|
||||
val replyTo = if (isBroadcast) BROADCAST_ADDR else MY_NODE
|
||||
val replyTo = if (isBroadcast) BROADCAST_ADDR else scenario.myNode
|
||||
|
||||
transportScope.handledLaunch {
|
||||
delay(REPLY_DELAY_MS)
|
||||
@@ -669,7 +671,7 @@ class MockRadioTransport(
|
||||
wb.last_sent_by_id = num
|
||||
wb.node_broadcast_interval_secs = 900
|
||||
wb.neighbors =
|
||||
SIM_PEERS.drop(1).take(3).map { neighbor ->
|
||||
scenario.peers.drop(1).take(3).map { neighbor ->
|
||||
Neighbor.Builder()
|
||||
.also { wb ->
|
||||
wb.node_id = neighbor.num
|
||||
@@ -702,7 +704,7 @@ class MockRadioTransport(
|
||||
wb.portnum = PortNum.NODE_STATUS_APP
|
||||
wb.payload =
|
||||
StatusMessage.Builder()
|
||||
.also { wb -> wb.status = PEER_NODE_STATUS }
|
||||
.also { wb -> wb.status = scenario.peerNodeStatus }
|
||||
.build()
|
||||
.encode()
|
||||
.toByteString()
|
||||
@@ -787,54 +789,14 @@ class MockRadioTransport(
|
||||
transportScope.handledLaunch {
|
||||
delay(ACK_DELAY_MS)
|
||||
lifecycle.runIfOpen {
|
||||
callback.handleFromRadio(makeAck(SIM_PEERS[DIRECT_PEER_INDEX].num, packet.from, packet.id).encode())
|
||||
val directPeer = scenario.peers[scenario.directPeerIndex]
|
||||
callback.handleFromRadio(makeAck(directPeer.num, packet.from, packet.id).encode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One simulated peer in the demo mesh. */
|
||||
private data class SimPeer(
|
||||
val num: Int,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val hwModel: HardwareModel,
|
||||
val role: Config.DeviceConfig.Role,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitude: Int,
|
||||
val batteryLevel: Int,
|
||||
val voltage: Float,
|
||||
val snr: Float,
|
||||
val rssi: Int,
|
||||
val hops: Int,
|
||||
val secondsSinceHeard: Int,
|
||||
val uptimeSeconds: Int,
|
||||
)
|
||||
|
||||
/** Latitude/longitude/altitude triple, converted to the proto's scaled-integer representation on demand. */
|
||||
private data class SimPosition(val latitude: Double, val longitude: Double, val altitude: Int) {
|
||||
fun toProto() = ProtoPosition.Builder()
|
||||
.also { wb ->
|
||||
wb.latitude_i = org.meshtastic.core.model.Position.degI(latitude)
|
||||
wb.longitude_i = org.meshtastic.core.model.Position.degI(longitude)
|
||||
wb.altitude = altitude
|
||||
wb.time = nowSeconds.toInt()
|
||||
// 32 bits is "full precision"; the coarse end of the scale draws a large uncertainty circle instead
|
||||
// of
|
||||
// placing the node where it actually is.
|
||||
wb.precision_bits = 32
|
||||
wb.sats_in_view = 9
|
||||
wb.location_source = ProtoPosition.LocSource.LOC_INTERNAL
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MY_NODE = 0x42424242
|
||||
const val BROADCAST_ADDR = -1 // 0xffffffff
|
||||
const val FIRMWARE_VERSION = "9.9.9.abcdefg"
|
||||
const val MY_NODE_STATUS = "Running Demo Mode — no radio attached."
|
||||
const val PEER_NODE_STATUS = "Solar powered, up on the ridge."
|
||||
const val AUTO_REPLY_TEXT = "Got it, thanks! Message received on the demo mesh."
|
||||
|
||||
/** First packet id handed out; low enough to stay clear of ids the app generates for its own sends. */
|
||||
@@ -851,9 +813,6 @@ class MockRadioTransport(
|
||||
|
||||
/** Hop budget the simulated nodes transmit with; `hop_limit` is derived so the app can infer hop distance. */
|
||||
const val DEFAULT_HOP_START = 3
|
||||
const val DIRECT_PEER_INDEX = 0
|
||||
const val TELEMETRY_PEER_COUNT = 4
|
||||
val WEATHER_PEER_INDEXES = listOf(4)
|
||||
|
||||
/** Spacing between seeded frames; the app timestamps rows on persist, so a burst would collapse together. */
|
||||
const val SEED_SPACING_MS = 120L
|
||||
@@ -864,168 +823,5 @@ class MockRadioTransport(
|
||||
const val ACK_DELAY_MS = 2_000L
|
||||
|
||||
val FAKE_SESSION_PASSKEY: okio.ByteString = okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77)
|
||||
|
||||
val MY_POSITION = SimPosition(latitude = 32.776665, longitude = -96.796989, altitude = 138)
|
||||
|
||||
/**
|
||||
* The demo mesh. Deterministic on purpose — the same mesh every launch makes the demo reproducible for
|
||||
* screenshots, support requests and store reviews. Spread over ~15 km so the map has something to fit.
|
||||
*/
|
||||
val SIM_PEERS =
|
||||
listOf(
|
||||
SimPeer(
|
||||
num = MY_NODE + 1,
|
||||
longName = "Riverside Base",
|
||||
shortName = "RVSD",
|
||||
hwModel = HardwareModel.HELTEC_V3,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = 32.802,
|
||||
longitude = -96.769,
|
||||
altitude = 152,
|
||||
batteryLevel = 92,
|
||||
voltage = 4.09f,
|
||||
snr = 11.5f,
|
||||
rssi = -62,
|
||||
hops = 0,
|
||||
secondsSinceHeard = 45,
|
||||
uptimeSeconds = 128_400,
|
||||
),
|
||||
SimPeer(
|
||||
num = MY_NODE + 2,
|
||||
longName = "Trail Runner",
|
||||
shortName = "TRLR",
|
||||
hwModel = HardwareModel.TRACKER_T1000_E,
|
||||
role = Config.DeviceConfig.Role.TRACKER,
|
||||
latitude = 32.7605,
|
||||
longitude = -96.8305,
|
||||
altitude = 145,
|
||||
batteryLevel = 64,
|
||||
voltage = 3.87f,
|
||||
snr = 6.25f,
|
||||
rssi = -84,
|
||||
hops = 0,
|
||||
secondsSinceHeard = 130,
|
||||
uptimeSeconds = 41_900,
|
||||
),
|
||||
SimPeer(
|
||||
num = MY_NODE + 3,
|
||||
longName = "Oak Cliff Repeater",
|
||||
shortName = "OAKR",
|
||||
hwModel = HardwareModel.RAK4631,
|
||||
role = Config.DeviceConfig.Role.ROUTER,
|
||||
latitude = 32.7395,
|
||||
longitude = -96.8215,
|
||||
altitude = 189,
|
||||
batteryLevel = 100,
|
||||
voltage = 4.14f,
|
||||
snr = 9.0f,
|
||||
rssi = -71,
|
||||
hops = 0,
|
||||
secondsSinceHeard = 20,
|
||||
uptimeSeconds = 903_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = MY_NODE + 4,
|
||||
longName = "Deep Ellum Handheld",
|
||||
shortName = "DEEP",
|
||||
hwModel = HardwareModel.T_DECK,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = 32.7842,
|
||||
longitude = -96.7845,
|
||||
altitude = 141,
|
||||
batteryLevel = 47,
|
||||
voltage = 3.74f,
|
||||
snr = -2.5f,
|
||||
rssi = -103,
|
||||
hops = 1,
|
||||
secondsSinceHeard = 320,
|
||||
uptimeSeconds = 9_800,
|
||||
),
|
||||
SimPeer(
|
||||
num = MY_NODE + 5,
|
||||
longName = "Rooftop Weather",
|
||||
shortName = "WTHR",
|
||||
hwModel = HardwareModel.HELTEC_MESH_NODE_T114,
|
||||
role = Config.DeviceConfig.Role.SENSOR,
|
||||
latitude = 32.8145,
|
||||
longitude = -96.8055,
|
||||
altitude = 205,
|
||||
batteryLevel = 88,
|
||||
voltage = 4.02f,
|
||||
snr = 4.75f,
|
||||
rssi = -91,
|
||||
hops = 1,
|
||||
secondsSinceHeard = 210,
|
||||
uptimeSeconds = 512_000,
|
||||
),
|
||||
SimPeer(
|
||||
num = MY_NODE + 6,
|
||||
longName = "Lakeside Solar",
|
||||
shortName = "LAKE",
|
||||
hwModel = HardwareModel.STATION_G2,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = 32.8365,
|
||||
longitude = -96.7325,
|
||||
altitude = 167,
|
||||
batteryLevel = 73,
|
||||
voltage = 3.94f,
|
||||
snr = 1.5f,
|
||||
rssi = -98,
|
||||
hops = 2,
|
||||
secondsSinceHeard = 640,
|
||||
uptimeSeconds = 254_300,
|
||||
),
|
||||
SimPeer(
|
||||
num = MY_NODE + 7,
|
||||
longName = "Bike Courier",
|
||||
shortName = "BIKE",
|
||||
hwModel = HardwareModel.TBEAM,
|
||||
role = Config.DeviceConfig.Role.TRACKER,
|
||||
latitude = 32.7688,
|
||||
longitude = -96.7492,
|
||||
altitude = 134,
|
||||
batteryLevel = 31,
|
||||
voltage = 3.62f,
|
||||
snr = -6.5f,
|
||||
rssi = -112,
|
||||
hops = 2,
|
||||
secondsSinceHeard = 1_180,
|
||||
uptimeSeconds = 3_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = MY_NODE + 8,
|
||||
longName = "Field Kit Echo",
|
||||
shortName = "ECHO",
|
||||
hwModel = HardwareModel.T_ECHO,
|
||||
role = Config.DeviceConfig.Role.CLIENT_MUTE,
|
||||
latitude = 32.7215,
|
||||
longitude = -96.7738,
|
||||
altitude = 158,
|
||||
batteryLevel = 56,
|
||||
voltage = 3.81f,
|
||||
snr = 3.25f,
|
||||
rssi = -95,
|
||||
hops = 1,
|
||||
secondsSinceHeard = 2_400,
|
||||
uptimeSeconds = 76_500,
|
||||
),
|
||||
)
|
||||
|
||||
/** Seeded channel conversation, as (peer index, text) pairs. Oldest first. */
|
||||
val CHANNEL_CONVERSATION =
|
||||
listOf(
|
||||
0 to "Morning all — base station is back online after the power cut.",
|
||||
2 to "Copy that. Repeater on Oak Cliff is holding steady, 100% battery.",
|
||||
1 to "Out on the trail loop, signal is solid the whole way today.",
|
||||
4 to "Rooftop sensor reading 18.5C and 47% humidity if anyone cares.",
|
||||
0 to "Nice. Net check complete, everyone reporting in.",
|
||||
)
|
||||
|
||||
/** Seeded direct-message thread from [DIRECT_PEER_INDEX]. Oldest first. */
|
||||
val DIRECT_CONVERSATION =
|
||||
listOf(
|
||||
"Hey, are you still planning to bring the spare antenna tomorrow?",
|
||||
"No rush — just let me know before you set off.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
* 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.network.radio
|
||||
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
/**
|
||||
* The mesh a [MockRadioTransport] simulates: our node, its peers, and the history it seeds on connect. The address
|
||||
* suffix after `m` picks it, so a scenario other than [DEMO] is reachable only by naming it, never from the picker.
|
||||
*/
|
||||
@Suppress("detekt:MagicNumber")
|
||||
internal data class MockScenario(
|
||||
val myNode: Int,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val hwModel: HardwareModel,
|
||||
val firmwareVersion: String,
|
||||
val nodeStatus: String,
|
||||
val position: SimPosition,
|
||||
val peers: List<SimPeer>,
|
||||
/** (index into [peers], text) pairs, oldest first. */
|
||||
val channelConversation: List<Pair<Int, String>>,
|
||||
val directPeerIndex: Int,
|
||||
/** Texts from [directPeerIndex] to us, oldest first. */
|
||||
val directConversation: List<String>,
|
||||
/** The first peers, which report device telemetry on connect and, with [liveTelemetry], every tick after. */
|
||||
val telemetryPeerCount: Int,
|
||||
val weatherPeerIndexes: List<Int>,
|
||||
/** Status line of peer 1, the one that reports a node status. */
|
||||
val peerNodeStatus: String,
|
||||
/** Keep reporting telemetry after the seed pass. Off where a stable picture matters more than a live one. */
|
||||
val liveTelemetry: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
/** The suffix that selects [SHOWCASE]: `mshowcase`. */
|
||||
const val SHOWCASE_ADDRESS = "showcase"
|
||||
|
||||
fun forAddress(address: String): MockScenario = if (address == SHOWCASE_ADDRESS) SHOWCASE else DEMO
|
||||
|
||||
private const val DEMO_NODE = 0x42424242
|
||||
|
||||
/**
|
||||
* Demo Mode's mesh. Deterministic on purpose, so the demo is the same every launch for support requests and
|
||||
* store reviews. Spread over ~15 km so the map has something to fit.
|
||||
*/
|
||||
val DEMO =
|
||||
MockScenario(
|
||||
myNode = DEMO_NODE,
|
||||
longName = "Demo Handset",
|
||||
shortName = "DEMO",
|
||||
hwModel = HardwareModel.ANDROID_SIM,
|
||||
firmwareVersion = "9.9.9.abcdefg",
|
||||
nodeStatus = "Running Demo Mode — no radio attached.",
|
||||
position = SimPosition(latitude = 32.776665, longitude = -96.796989, altitude = 138),
|
||||
peers =
|
||||
listOf(
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 1,
|
||||
longName = "Riverside Base",
|
||||
shortName = "RVSD",
|
||||
hwModel = HardwareModel.HELTEC_V3,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = 32.802,
|
||||
longitude = -96.769,
|
||||
altitude = 152,
|
||||
batteryLevel = 92,
|
||||
voltage = 4.09f,
|
||||
snr = 11.5f,
|
||||
rssi = -62,
|
||||
hops = 0,
|
||||
secondsSinceHeard = 45,
|
||||
uptimeSeconds = 128_400,
|
||||
),
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 2,
|
||||
longName = "Trail Runner",
|
||||
shortName = "TRLR",
|
||||
hwModel = HardwareModel.TRACKER_T1000_E,
|
||||
role = Config.DeviceConfig.Role.TRACKER,
|
||||
latitude = 32.7605,
|
||||
longitude = -96.8305,
|
||||
altitude = 145,
|
||||
batteryLevel = 64,
|
||||
voltage = 3.87f,
|
||||
snr = 6.25f,
|
||||
rssi = -84,
|
||||
hops = 0,
|
||||
secondsSinceHeard = 130,
|
||||
uptimeSeconds = 41_900,
|
||||
),
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 3,
|
||||
longName = "Oak Cliff Repeater",
|
||||
shortName = "OAKR",
|
||||
hwModel = HardwareModel.RAK4631,
|
||||
role = Config.DeviceConfig.Role.ROUTER,
|
||||
latitude = 32.7395,
|
||||
longitude = -96.8215,
|
||||
altitude = 189,
|
||||
batteryLevel = 100,
|
||||
voltage = 4.14f,
|
||||
snr = 9.0f,
|
||||
rssi = -71,
|
||||
hops = 0,
|
||||
secondsSinceHeard = 20,
|
||||
uptimeSeconds = 903_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 4,
|
||||
longName = "Deep Ellum Handheld",
|
||||
shortName = "DEEP",
|
||||
hwModel = HardwareModel.T_DECK,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = 32.7842,
|
||||
longitude = -96.7845,
|
||||
altitude = 141,
|
||||
batteryLevel = 47,
|
||||
voltage = 3.74f,
|
||||
snr = -2.5f,
|
||||
rssi = -103,
|
||||
hops = 1,
|
||||
secondsSinceHeard = 320,
|
||||
uptimeSeconds = 9_800,
|
||||
),
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 5,
|
||||
longName = "Rooftop Weather",
|
||||
shortName = "WTHR",
|
||||
hwModel = HardwareModel.HELTEC_MESH_NODE_T114,
|
||||
role = Config.DeviceConfig.Role.SENSOR,
|
||||
latitude = 32.8145,
|
||||
longitude = -96.8055,
|
||||
altitude = 205,
|
||||
batteryLevel = 88,
|
||||
voltage = 4.02f,
|
||||
snr = 4.75f,
|
||||
rssi = -91,
|
||||
hops = 1,
|
||||
secondsSinceHeard = 210,
|
||||
uptimeSeconds = 512_000,
|
||||
),
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 6,
|
||||
longName = "Lakeside Solar",
|
||||
shortName = "LAKE",
|
||||
hwModel = HardwareModel.STATION_G2,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = 32.8365,
|
||||
longitude = -96.7325,
|
||||
altitude = 167,
|
||||
batteryLevel = 73,
|
||||
voltage = 3.94f,
|
||||
snr = 1.5f,
|
||||
rssi = -98,
|
||||
hops = 2,
|
||||
secondsSinceHeard = 640,
|
||||
uptimeSeconds = 254_300,
|
||||
),
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 7,
|
||||
longName = "Bike Courier",
|
||||
shortName = "BIKE",
|
||||
hwModel = HardwareModel.TBEAM,
|
||||
role = Config.DeviceConfig.Role.TRACKER,
|
||||
latitude = 32.7688,
|
||||
longitude = -96.7492,
|
||||
altitude = 134,
|
||||
batteryLevel = 31,
|
||||
voltage = 3.62f,
|
||||
snr = -6.5f,
|
||||
rssi = -112,
|
||||
hops = 2,
|
||||
secondsSinceHeard = 1_180,
|
||||
uptimeSeconds = 3_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = DEMO_NODE + 8,
|
||||
longName = "Field Kit Echo",
|
||||
shortName = "ECHO",
|
||||
hwModel = HardwareModel.T_ECHO,
|
||||
role = Config.DeviceConfig.Role.CLIENT_MUTE,
|
||||
latitude = 32.7215,
|
||||
longitude = -96.7738,
|
||||
altitude = 158,
|
||||
batteryLevel = 56,
|
||||
voltage = 3.81f,
|
||||
snr = 3.25f,
|
||||
rssi = -95,
|
||||
hops = 1,
|
||||
secondsSinceHeard = 2_400,
|
||||
uptimeSeconds = 76_500,
|
||||
),
|
||||
),
|
||||
channelConversation =
|
||||
listOf(
|
||||
0 to "Morning all — base station is back online after the power cut.",
|
||||
2 to "Copy that. Repeater on Oak Cliff is holding steady, 100% battery.",
|
||||
1 to "Out on the trail loop, signal is solid the whole way today.",
|
||||
4 to "Rooftop sensor reading 18.5C and 47% humidity if anyone cares.",
|
||||
0 to "Nice. Net check complete, everyone reporting in.",
|
||||
),
|
||||
directPeerIndex = 0,
|
||||
directConversation =
|
||||
listOf(
|
||||
"Hey, are you still planning to bring the spare antenna tomorrow?",
|
||||
"No rush — just let me know before you set off.",
|
||||
),
|
||||
telemetryPeerCount = 4,
|
||||
weatherPeerIndexes = listOf(4),
|
||||
peerNodeStatus = "Solar powered, up on the ridge.",
|
||||
liveTelemetry = true,
|
||||
)
|
||||
|
||||
private const val SHOWCASE_NODE = 0x2b3c4d5e
|
||||
private const val SHOWCASE_LAT = 32.7767
|
||||
private const val SHOWCASE_LON = -96.797
|
||||
|
||||
/** A peer [minutes] ago, 30 s into that minute so a slow frame cannot tip a "3 min" label over. */
|
||||
private fun heard(minutes: Int): Int = if (minutes == 0) 5 else minutes * 60 + 30
|
||||
|
||||
/**
|
||||
* The store listing's mesh: a hiking group around a base camp, every node named for a place or a person, and a
|
||||
* channel thread that tells one morning. Static after the seed pass, so every capture shows the same numbers.
|
||||
*/
|
||||
val SHOWCASE =
|
||||
MockScenario(
|
||||
myNode = SHOWCASE_NODE,
|
||||
longName = "Base Camp",
|
||||
shortName = "BASE",
|
||||
hwModel = HardwareModel.HELTEC_V3,
|
||||
firmwareVersion = "2.7.26.54e0d8d",
|
||||
nodeStatus = "Base camp radio, on grid power.",
|
||||
position = SimPosition(latitude = SHOWCASE_LAT, longitude = SHOWCASE_LON, altitude = 150),
|
||||
peers =
|
||||
listOf(
|
||||
SimPeer(
|
||||
num = 0x1a2b3c4d,
|
||||
longName = "Ridge Top",
|
||||
shortName = "RDGE",
|
||||
hwModel = HardwareModel.RAK4631,
|
||||
role = Config.DeviceConfig.Role.ROUTER,
|
||||
latitude = SHOWCASE_LAT + 0.04,
|
||||
longitude = SHOWCASE_LON + 0.03,
|
||||
altitude = 214,
|
||||
batteryLevel = 88,
|
||||
voltage = 4.02f,
|
||||
snr = 11.5f,
|
||||
rssi = -68,
|
||||
hops = 0,
|
||||
secondsSinceHeard = heard(2),
|
||||
uptimeSeconds = 19 * 86_400 + 7 * 3_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = 0x5e6f7081,
|
||||
longName = "Summit Solar",
|
||||
shortName = "SMMT",
|
||||
hwModel = HardwareModel.RAK4631,
|
||||
role = Config.DeviceConfig.Role.ROUTER,
|
||||
latitude = SHOWCASE_LAT + 0.05,
|
||||
longitude = SHOWCASE_LON - 0.04,
|
||||
altitude = 249,
|
||||
batteryLevel = 91,
|
||||
voltage = 4.09f,
|
||||
snr = 3.5f,
|
||||
rssi = -110,
|
||||
hops = 2,
|
||||
secondsSinceHeard = heard(15),
|
||||
uptimeSeconds = 63 * 86_400 + 2 * 3_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = 0x3c4d5e6f,
|
||||
longName = "Trailhead",
|
||||
shortName = "TRLH",
|
||||
hwModel = HardwareModel.TBEAM,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = SHOWCASE_LAT - 0.03,
|
||||
longitude = SHOWCASE_LON + 0.045,
|
||||
altitude = 231,
|
||||
batteryLevel = 72,
|
||||
voltage = 3.89f,
|
||||
snr = 8.25f,
|
||||
rssi = -92,
|
||||
hops = 1,
|
||||
secondsSinceHeard = heard(3),
|
||||
uptimeSeconds = 6 * 3_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = 0x8192a3b4.toInt(),
|
||||
longName = "Sarah's Truck",
|
||||
shortName = "SRAH",
|
||||
hwModel = HardwareModel.T_DECK,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = SHOWCASE_LAT + 0.025,
|
||||
longitude = SHOWCASE_LON + 0.01,
|
||||
altitude = 176,
|
||||
batteryLevel = 83,
|
||||
voltage = 3.98f,
|
||||
snr = 9.0f,
|
||||
rssi = -85,
|
||||
hops = 1,
|
||||
secondsSinceHeard = heard(4),
|
||||
uptimeSeconds = 4 * 3_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = 0x4d5e6f70,
|
||||
longName = "River Crossing",
|
||||
shortName = "RIVR",
|
||||
hwModel = HardwareModel.T_ECHO,
|
||||
role = Config.DeviceConfig.Role.CLIENT_MUTE,
|
||||
latitude = SHOWCASE_LAT - 0.045,
|
||||
longitude = SHOWCASE_LON - 0.02,
|
||||
altitude = 128,
|
||||
batteryLevel = 64,
|
||||
voltage = 3.84f,
|
||||
snr = 6.0f,
|
||||
rssi = -101,
|
||||
hops = 1,
|
||||
secondsSinceHeard = heard(7),
|
||||
uptimeSeconds = 2 * 86_400,
|
||||
),
|
||||
SimPeer(
|
||||
num = 0xa3b4c5d6.toInt(),
|
||||
longName = "Ham Shack",
|
||||
shortName = "SHCK",
|
||||
hwModel = HardwareModel.STATION_G2,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = SHOWCASE_LAT - 0.02,
|
||||
longitude = SHOWCASE_LON - 0.035,
|
||||
altitude = 162,
|
||||
batteryLevel = 77,
|
||||
voltage = 3.92f,
|
||||
snr = 4.5f,
|
||||
rssi = -104,
|
||||
hops = 2,
|
||||
secondsSinceHeard = heard(11),
|
||||
uptimeSeconds = 41 * 86_400,
|
||||
),
|
||||
SimPeer(
|
||||
num = 0x6f708192,
|
||||
longName = "Kayak Dan",
|
||||
shortName = "KDAN",
|
||||
hwModel = HardwareModel.HELTEC_WIRELESS_TRACKER,
|
||||
role = Config.DeviceConfig.Role.TRACKER,
|
||||
latitude = SHOWCASE_LAT + 0.012,
|
||||
longitude = SHOWCASE_LON - 0.042,
|
||||
altitude = 120,
|
||||
batteryLevel = 58,
|
||||
voltage = 3.79f,
|
||||
snr = 2.75f,
|
||||
rssi = -113,
|
||||
hops = 2,
|
||||
secondsSinceHeard = heard(25),
|
||||
uptimeSeconds = 3 * 3_600,
|
||||
),
|
||||
SimPeer(
|
||||
num = 0x708192a3,
|
||||
longName = "Old Fire Lookout",
|
||||
shortName = "LOOK",
|
||||
hwModel = HardwareModel.TBEAM,
|
||||
role = Config.DeviceConfig.Role.CLIENT,
|
||||
latitude = SHOWCASE_LAT - 0.01,
|
||||
longitude = SHOWCASE_LON + 0.02,
|
||||
altitude = 268,
|
||||
batteryLevel = 47,
|
||||
voltage = 3.72f,
|
||||
snr = -1.5f,
|
||||
rssi = -118,
|
||||
hops = 3,
|
||||
secondsSinceHeard = heard(60),
|
||||
uptimeSeconds = 9 * 86_400,
|
||||
),
|
||||
),
|
||||
channelConversation =
|
||||
listOf(
|
||||
2 to "Heading up from the trailhead now, 4 of us",
|
||||
3 to "Parked at the overflow lot, radio on",
|
||||
2 to "Made the ridge, good signal back to base",
|
||||
6 to "Water at the crossing is low, safe to ford",
|
||||
3 to "Bringing the truck around to the lower lot at 3",
|
||||
2 to "Lunch at the lookout, back on the air in 30",
|
||||
),
|
||||
directPeerIndex = 6,
|
||||
directConversation = listOf("Paddling past the crossing now.", "Can you see me on the map yet?"),
|
||||
telemetryPeerCount = 4,
|
||||
weatherPeerIndexes = listOf(0),
|
||||
peerNodeStatus = "Solar powered, up on the summit.",
|
||||
liveTelemetry = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class SimPeer(
|
||||
val num: Int,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val hwModel: HardwareModel,
|
||||
val role: Config.DeviceConfig.Role,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitude: Int,
|
||||
val batteryLevel: Int,
|
||||
val voltage: Float,
|
||||
val snr: Float,
|
||||
val rssi: Int,
|
||||
val hops: Int,
|
||||
val secondsSinceHeard: Int,
|
||||
val uptimeSeconds: Int,
|
||||
)
|
||||
|
||||
/** Latitude/longitude/altitude triple, converted to the proto's scaled-integer representation on demand. */
|
||||
@Suppress("detekt:MagicNumber")
|
||||
internal data class SimPosition(val latitude: Double, val longitude: Double, val altitude: Int) {
|
||||
fun toProto() = ProtoPosition.Builder()
|
||||
.also { wb ->
|
||||
wb.latitude_i = org.meshtastic.core.model.Position.degI(latitude)
|
||||
wb.longitude_i = org.meshtastic.core.model.Position.degI(longitude)
|
||||
wb.altitude = altitude
|
||||
wb.time = nowSeconds.toInt()
|
||||
// 32 bits is "full precision"; the coarse end of the scale draws a large uncertainty circle instead of
|
||||
// placing the node where it actually is.
|
||||
wb.precision_bits = 32
|
||||
wb.sats_in_view = 9
|
||||
wb.location_source = ProtoPosition.LocSource.LOC_INTERNAL
|
||||
}
|
||||
.build()
|
||||
}
|
||||
+59
@@ -509,6 +509,65 @@ class MockRadioTransportTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the showcase address plays the showcase mesh under its own identity`() = runTest {
|
||||
val callback = RecordingCallback()
|
||||
val scope = transportScope()
|
||||
try {
|
||||
val transport = MockRadioTransport(callback, scope, address = MockScenario.SHOWCASE_ADDRESS)
|
||||
transport.handleSendToRadio(wantConfig(HandshakeConstants.CONFIG_NONCE))
|
||||
transport.handleSendToRadio(wantConfig(HandshakeConstants.NODE_INFO_NONCE))
|
||||
|
||||
val myInfo = assertNotNull(callback.received.firstNotNullOfOrNull { it.my_info })
|
||||
assertEquals(MockScenario.SHOWCASE.myNode, myInfo.my_node_num)
|
||||
val names = callback.nodeInfos.map { it.user?.long_name }
|
||||
assertEquals(listOf("Base Camp") + MockScenario.SHOWCASE.peers.map { it.longName }, names)
|
||||
assertFalse(names.contains("Demo Handset"), "the showcase must not carry Demo Mode's identity")
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any other address plays the demo mesh`() = runTest {
|
||||
val callback = RecordingCallback()
|
||||
val scope = transportScope()
|
||||
try {
|
||||
val transport = MockRadioTransport(callback, scope, address = "anything")
|
||||
transport.handleSendToRadio(wantConfig(HandshakeConstants.CONFIG_NONCE))
|
||||
transport.handleSendToRadio(wantConfig(HandshakeConstants.NODE_INFO_NONCE))
|
||||
|
||||
assertEquals(MockScenario.DEMO.myNode, callback.received.firstNotNullOfOrNull { it.my_info }?.my_node_num)
|
||||
assertEquals("Demo Handset", callback.nodeInfos.first().user?.long_name)
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the showcase seeds its thread once and then stays still`() = runTest {
|
||||
val callback = RecordingCallback()
|
||||
val scope = transportScope()
|
||||
try {
|
||||
val transport = MockRadioTransport(callback, scope, address = MockScenario.SHOWCASE_ADDRESS)
|
||||
transport.handleSendToRadio(wantConfig(HandshakeConstants.CONFIG_NONCE))
|
||||
transport.handleSendToRadio(wantConfig(HandshakeConstants.NODE_INFO_NONCE))
|
||||
testScheduler.advanceTimeBy(SEED_WINDOW_MS)
|
||||
|
||||
val texts = callback.packetsOn(PortNum.TEXT_MESSAGE_APP)
|
||||
assertEquals(
|
||||
MockScenario.SHOWCASE.channelConversation.size + MockScenario.SHOWCASE.directConversation.size,
|
||||
texts.size,
|
||||
)
|
||||
val afterSeed = callback.received.size
|
||||
|
||||
testScheduler.advanceTimeBy(LIVE_TICK_MS * LIVE_TICKS_AFTER_CLOSE)
|
||||
assertEquals(afterSeed, callback.received.size, "a capture must see the same numbers however late it runs")
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BROADCAST_ADDR = -1
|
||||
const val MIN_DEMO_NODES = 8
|
||||
|
||||
+13
-2
@@ -26,10 +26,12 @@ import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.network.SerialTransport
|
||||
import org.meshtastic.core.network.radio.BaseRadioTransportFactory
|
||||
import org.meshtastic.core.network.radio.MockRadioTransport
|
||||
import org.meshtastic.core.network.radio.TcpRadioTransport
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
import org.meshtastic.desktop.DesktopBuildConfig
|
||||
|
||||
/**
|
||||
* Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing
|
||||
@@ -47,13 +49,22 @@ class DesktopRadioTransportFactory(
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
|
||||
|
||||
// Desktop has no unlock gesture and no demo entry in its picker; the virtual transports stay inadmissible.
|
||||
override val mockTransportEnabled: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
// Desktop has no unlock gesture, so Demo Mode is a debug-build feature: it admits the `m` addresses, including
|
||||
// the hidden showcase, and offers the demo entry in the picker.
|
||||
override val mockTransportEnabled: StateFlow<Boolean> = MutableStateFlow(DesktopBuildConfig.IS_DEBUG)
|
||||
|
||||
/** Desktop bundles no capture asset, and [createPlatformTransport] does not wire a replay address. */
|
||||
override val isReplayTransportAvailable: Boolean = false
|
||||
|
||||
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
|
||||
address.startsWith(InterfaceId.MOCK.id) -> {
|
||||
MockRadioTransport(
|
||||
callback = service,
|
||||
scope = service.serviceScope,
|
||||
address = address.removePrefix(InterfaceId.MOCK.id.toString()),
|
||||
)
|
||||
}
|
||||
|
||||
address.startsWith(InterfaceId.TCP.id) -> {
|
||||
TcpRadioTransport(
|
||||
callback = service,
|
||||
|
||||
@@ -110,6 +110,8 @@ manifest entry fails CI.
|
||||
| `/firmware` | `FirmwareRoute.FirmwareGraph` | Firmware screen |
|
||||
| `/firmware/update` | `FirmwareRoute.FirmwareUpdate` | Firmware update flow |
|
||||
|
||||
Demo Mode's addresses start with `m`. In a debug build, or once hidden features are unlocked, `/connections?address=m` connects to the demo mesh and `/connections?address=mshowcase` to the store-screenshot mesh, which the device picker never lists. `MockScenario` in `:core:network` holds both.
|
||||
|
||||
### Backstack synthesis
|
||||
|
||||
Deep links synthesize a full backstack, not just the target screen:
|
||||
|
||||
Reference in new issue
Block a user