feat(map): send a waypoint as a DM or to a specific channel (#6218)

This commit is contained in:
Joey Leake
2026-07-11 13:17:30 -04:00
committed by GitHub
parent 774a35fdcc
commit 8f3bf001cc
16 changed files with 693 additions and 25 deletions

View File

@@ -1708,10 +1708,14 @@ voltage
wait_for_bluetooth_duration_seconds
wake_on_tap_or_motion
warning
### WAYPOINT ###
waypoint_delete
waypoint_edit
waypoint_new
waypoint_received
waypoint_recipient_broadcast
waypoint_recipient_channel
waypoint_send_to
weight
### WIFI ###
wifi_config

View File

@@ -263,6 +263,9 @@ fun MapView(
// tile-download rectangle machinery) that becomes the draft's bounding box on confirm. Kept distinct from the
// tile-download box (downloadRegionBoundingBox) so the two rectangle modes never collide.
var geofenceBoxDraft by remember { mutableStateOf<Waypoint?>(null) }
// The recipient selected in EditWaypointDialog before "Set area" tore it down — carried across the round trip
// so re-opening the dialog with the drawn box doesn't silently reset the destination back to Broadcast.
var geofenceBoxDraftContactKey by remember { mutableStateOf<String?>(null) }
var geofenceBoxBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
@@ -480,8 +483,10 @@ fun MapView(
waypoint.toGeofence() != null && !mapViewModel.isMyWaypoint(id) -> showGeofenceInfoDialog = waypoint
// edit only when unlocked or lockedTo myNodeNum
waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected ->
waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected -> {
geofenceBoxDraftContactKey = null
showEditWaypointDialog = waypoint
}
else -> showDeleteWaypointDialog = waypoint
}
@@ -583,6 +588,7 @@ fun MapView(
val enabled = isConnected && downloadRegionBoundingBox == null && geofenceBoxDraft == null
if (enabled) {
geofenceBoxDraftContactKey = null
showEditWaypointDialog =
Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt())
}
@@ -894,7 +900,16 @@ fun MapView(
EditWaypointDialog(
waypoint = showEditWaypointDialog ?: return, // Safe call
displayUnits = displayUnits,
onSend = { waypoint ->
nodes = nodes,
ourNode = ourNodeInfo,
channelSet = channelSet,
initialContactKey =
geofenceBoxDraftContactKey
?: showEditWaypointDialog
?.let { wp -> waypoints[wp.id] }
?.let { mapViewModel.waypointContactKey(it) }
?: "0${NodeAddress.ID_BROADCAST}",
onSend = { waypoint, contactKey ->
Logger.d { "User clicked send waypoint ${waypoint.id}" }
showEditWaypointDialog = null
@@ -912,6 +927,7 @@ fun MapView(
locked_to = newLockedTo,
icon = newIcon,
),
contactKey,
)
},
onDelete = { waypoint ->
@@ -923,10 +939,11 @@ fun MapView(
Logger.d { "User clicked cancel marker edit dialog" }
showEditWaypointDialog = null
},
onBeginBoxAuthoring = { draft ->
onBeginBoxAuthoring = { draft, contactKey ->
Logger.d { "User began geofence box authoring for waypoint ${draft.id}" }
showEditWaypointDialog = null
geofenceBoxDraft = draft
geofenceBoxDraftContactKey = contactKey
map.generateGeofenceBoxOverlay()
},
)
@@ -946,6 +963,7 @@ fun MapView(
if (waypoint.locked_to == 0 && isConnected) {
{
showGeofenceInfoDialog = null
geofenceBoxDraftContactKey = null
showEditWaypointDialog = waypoint
}
} else {
@@ -979,7 +997,13 @@ fun MapView(
TextButton(
onClick = {
Logger.d { "User deleted waypoint ${waypoint.id} for everyone" }
mapViewModel.sendWaypoint(waypoint.copy(expire = 1))
// Route the expiry to wherever the waypoint was originally sent (a DM or a secondary
// channel), not the default broadcast — otherwise a DM'd waypoint's "delete for
// everyone" never reaches its actual recipient.
val originalContactKey =
waypoints[waypoint.id]?.let { mapViewModel.waypointContactKey(it) }
?: "0${NodeAddress.ID_BROADCAST}"
mapViewModel.sendWaypoint(waypoint.copy(expire = 1), originalContactKey)
mapViewModel.deleteWaypoint(waypoint.id)
showDeleteWaypointDialog = null
},

View File

@@ -31,6 +31,7 @@ import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import java.io.InputStream
@@ -44,6 +45,7 @@ class MapViewModel(
radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
notificationPrefs: NotificationPrefs,
uiPrefs: UiPrefs,
buildConfigProvider: BuildConfigProvider,
private val mapLayersManager: MapLayersManager,
savedStateHandle: SavedStateHandle,
@@ -54,6 +56,7 @@ class MapViewModel(
radioController,
radioConfigRepository,
notificationPrefs,
uiPrefs,
) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))

View File

@@ -114,6 +114,7 @@ import org.meshtastic.app.map.component.WaypointMarkers
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.util.GeoConstants.DEG_D
@@ -262,6 +263,9 @@ fun MapView(
// --- Geofence box authoring (Main mode) ---
// When non-null, the user is defining a bounding box for [boxAuthoringDraft] by tapping two corners.
var boxAuthoringDraft by remember { mutableStateOf<Waypoint?>(null) }
// The recipient selected in EditWaypointDialog before box authoring tore it down — carried across the round trip
// so re-opening the dialog with the drawn box doesn't silently reset the destination back to Broadcast.
var boxAuthoringDraftContactKey by remember { mutableStateOf<String?>(null) }
var boxAuthoringFirstCorner by remember { mutableStateOf<LatLng?>(null) }
var boxAuthoringSecondCorner by remember { mutableStateOf<LatLng?>(null) }
@@ -346,6 +350,7 @@ fun MapView(
DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
// --- Node & waypoint data ---
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint }
@@ -598,6 +603,7 @@ fun MapView(
},
onMapLongClick = { latLng ->
if (isMainMode && isConnected && boxAuthoringDraft == null) {
boxAuthoringDraftContactKey = null
editingWaypoint =
Waypoint(
latitude_i = (latLng.latitude / DEG_D).toInt(),
@@ -656,7 +662,10 @@ fun MapView(
displayableWaypoints = displayableWaypoints,
myNodeNum = myNodeNum,
isConnected = isConnected,
onEditWaypointRequest = { editingWaypoint = it },
onEditWaypointRequest = {
boxAuthoringDraftContactKey = null
editingWaypoint = it
},
onShowGeofenceInfo = { geofenceInfoWaypoint = it },
selectedWaypointId = selectedWaypointId,
mapLayers = mapLayers,
@@ -702,7 +711,14 @@ fun MapView(
EditWaypointDialog(
waypoint = waypointToEdit,
displayUnits = displayUnits,
onSend = { updatedWp ->
nodes = nodes,
ourNode = ourNodeInfo,
channelSet = channelSet,
initialContactKey =
boxAuthoringDraftContactKey
?: waypoints[waypointToEdit.id]?.let { mapViewModel.waypointContactKey(it) }
?: "0${NodeAddress.ID_BROADCAST}",
onSend = { updatedWp, contactKey ->
var finalWp = updatedWp
if (updatedWp.id == 0) {
finalWp = finalWp.copy(id = mapViewModel.generatePacketId())
@@ -710,19 +726,26 @@ fun MapView(
if (updatedWp.icon == 0) {
finalWp = finalWp.copy(icon = 0x1F4CD)
}
mapViewModel.sendWaypoint(finalWp)
mapViewModel.sendWaypoint(finalWp, contactKey)
editingWaypoint = null
},
onDelete = { wpToDelete ->
if (wpToDelete.locked_to == 0 && isConnected && wpToDelete.id != 0) {
mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
// Route the expiry to wherever the waypoint was originally sent (a DM or a secondary
// channel), not the default broadcast — otherwise a DM'd waypoint's deletion never
// reaches its actual recipient.
val originalContactKey =
waypoints[wpToDelete.id]?.let { mapViewModel.waypointContactKey(it) }
?: "0${NodeAddress.ID_BROADCAST}"
mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1), originalContactKey)
}
mapViewModel.deleteWaypoint(wpToDelete.id)
editingWaypoint = null
},
onDismissRequest = { editingWaypoint = null },
onBeginBoxAuthoring = { draft ->
onBeginBoxAuthoring = { draft, contactKey ->
boxAuthoringDraft = draft
boxAuthoringDraftContactKey = contactKey
boxAuthoringFirstCorner = null
boxAuthoringSecondCorner = null
editingWaypoint = null
@@ -744,6 +767,7 @@ fun MapView(
if (waypoint.locked_to == 0 && isConnected) {
{
geofenceInfoWaypoint = null
boxAuthoringDraftContactKey = null
editingWaypoint = waypoint
}
} else {

View File

@@ -97,6 +97,7 @@ class MapViewModel(
radioController,
radioConfigRepository,
notificationPrefs,
uiPrefs,
) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))

View File

@@ -34,4 +34,10 @@ enum class NodeSortOption(val sqlValue: String, val stringRes: StringResource) {
CHANNEL("channel", Res.string.node_sort_channel),
VIA_MQTT("via_mqtt", Res.string.node_sort_via_mqtt),
VIA_FAVORITE("via_favorite", Res.string.node_sort_via_favorite),
;
companion object {
/** Maps a persisted preference ordinal (e.g. `UiPrefs.nodeSort`) back to its option. */
fun fromOrdinal(ordinal: Int): NodeSortOption = entries.getOrElse(ordinal) { VIA_FAVORITE }
}
}

View File

@@ -16,8 +16,11 @@
*/
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.NodeSortOption
/** Reactive interface for analytics-related preferences. */
interface AnalyticsPrefs {
@@ -194,6 +197,10 @@ interface UiPrefs {
fun setShouldShowTelemetry(value: Boolean)
}
/** Maps the persisted [UiPrefs.nodeSort] ordinal to its [NodeSortOption], the single source every consumer shares. */
val UiPrefs.nodeSortOption: Flow<NodeSortOption>
get() = nodeSort.map { NodeSortOption.fromOrdinal(it) }
/** Reactive interface for notification preferences. */
interface NotificationPrefs {
val messagesEnabled: StateFlow<Boolean>

View File

@@ -1753,10 +1753,14 @@
<string name="wait_for_bluetooth_duration_seconds">Wait for Bluetooth duration</string>
<string name="wake_on_tap_or_motion">Wake on tap or motion</string>
<string name="warning">Warning</string>
<!-- WAYPOINT -->
<string name="waypoint_delete">Delete waypoint?</string>
<string name="waypoint_edit">Edit waypoint</string>
<string name="waypoint_new">New waypoint</string>
<string name="waypoint_received">Received waypoint: %1$s</string>
<string name="waypoint_recipient_broadcast">Broadcast (all nodes)</string>
<string name="waypoint_recipient_channel">Channel %1$d</string>
<string name="waypoint_send_to">Send to</string>
<string name="weight">Weight</string>
<!-- WIFI -->
<string name="wifi_config">WiFi Options</string>

View File

@@ -37,5 +37,16 @@ kotlin {
implementation(projects.core.ui)
implementation(projects.core.di)
}
getByName("androidHostTest") {
dependencies {
implementation(projects.core.testing)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.compose.multiplatform.ui.test)
// Registers a ComponentActivity in the test manifest so Robolectric's runComposeUiTest has a host
// activity to attach to (feature/map is a library module with no launcher activity of its own).
implementation(libs.androidx.compose.ui.test.manifest)
}
}
}
}

View File

@@ -0,0 +1,252 @@
/*
* 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.map.component
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runComposeUiTest
import okio.ByteString.Companion.encodeUtf8
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.node_filter_placeholder
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.waypoint_recipient_broadcast
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@OptIn(ExperimentalTestApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class EditWaypointDialogTest {
private val waypoint = Waypoint(id = 1, name = "Test Waypoint")
private val node = TestDataFactory.createTestNode(num = 42, userId = "!a1b2c3d4", longName = "Alice")
private val channelSet =
ChannelSet(settings = listOf(ChannelSettings(name = "Primary"), ChannelSettings(name = "Secondary")))
@Test
fun sendDefaultsToPrimaryChannel() = runComposeUiTest {
var sentContactKey: String? = null
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(node),
ourNode = null,
channelSet = channelSet,
onSend = { _, contactKey -> sentContactKey = contactKey },
onDelete = {},
onDismissRequest = {},
)
}
// The primary channel (index 0) is labelled with its configured name, not a generic "Broadcast" string.
onNodeWithText("Primary").assertIsDisplayed()
onNodeWithText(getString(Res.string.send)).performClick()
assertEquals("0${NodeAddress.ID_BROADCAST}", sentContactKey)
}
@Test
fun sendDefaultsToGenericBroadcastLabelWhenChannelSetUnavailable() = runComposeUiTest {
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(node),
ourNode = null,
channelSet = null,
onSend = { _, _ -> },
onDelete = {},
onDismissRequest = {},
)
}
// No channel info (e.g. disconnected) falls back to the generic "Broadcast" label.
onNodeWithText(getString(Res.string.waypoint_recipient_broadcast)).assertIsDisplayed()
}
@Test
fun sendOffersOnlyBroadcastWhenChannelSetSettingsIsEmpty() = runComposeUiTest {
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(node),
ourNode = null,
// Non-null but empty settings (e.g. right after a fresh install, before channel sync completes) must
// still offer a Broadcast option rather than rendering zero channel rows.
channelSet = ChannelSet(settings = emptyList()),
onSend = { _, _ -> },
onDelete = {},
onDismissRequest = {},
)
}
onNodeWithText(getString(Res.string.waypoint_recipient_broadcast)).assertIsDisplayed()
}
@Test
fun pickingANonPkcNodeRoutesViaItsOwnChannel() = runComposeUiTest {
var sentContactKey: String? = null
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(node),
ourNode = null,
channelSet = channelSet,
onSend = { _, contactKey -> sentContactKey = contactKey },
onDelete = {},
onDismissRequest = {},
)
}
// The clickable element is the TextButton showing the *current* recipient value ("Primary" before any
// selection), not the static "Send to" label beside it.
onNodeWithText("Primary").performClick()
onNodeWithText("Alice").assertIsDisplayed()
onNodeWithText("Alice").performClick()
onNodeWithText(getString(Res.string.send)).performClick()
// Neither this node nor our local node advertises PKC, so the DM routes on the node's own channel (0),
// not the PKC channel index — matching NodeListViewModel.getDirectMessageRoute's convention.
assertEquals("0!a1b2c3d4", sentContactKey)
}
@Test
fun pickingAPkcCapableNodeRoutesViaPkcChannel() = runComposeUiTest {
var sentContactKey: String? = null
val pkcNode = node.copy(publicKey = "node-key".encodeUtf8())
val ourPkcNode =
TestDataFactory.createTestNode(num = 1, userId = "!local0001").copy(publicKey = "our-key".encodeUtf8())
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(pkcNode),
ourNode = ourPkcNode,
channelSet = channelSet,
onSend = { _, contactKey -> sentContactKey = contactKey },
onDelete = {},
onDismissRequest = {},
)
}
onNodeWithText("Primary").performClick()
onNodeWithText("Alice").performClick()
onNodeWithText(getString(Res.string.send)).performClick()
// Both ends support PKC, so the DM routes on the dedicated PKC channel index.
assertEquals("${NodeAddress.PKC_CHANNEL_INDEX}!a1b2c3d4", sentContactKey)
}
@Test
fun pickingASecondaryChannelRoutesSendToThatChannel() = runComposeUiTest {
var sentContactKey: String? = null
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(node),
ourNode = null,
channelSet = channelSet,
onSend = { _, contactKey -> sentContactKey = contactKey },
onDelete = {},
onDismissRequest = {},
)
}
onNodeWithText("Primary").performClick()
onNodeWithText("Secondary").assertIsDisplayed()
onNodeWithText("Secondary").performClick()
onNodeWithText(getString(Res.string.send)).performClick()
assertEquals("1${NodeAddress.ID_BROADCAST}", sentContactKey)
}
@Test
fun initialContactKeySeedsTheSelectedRecipient() = runComposeUiTest {
var sentContactKey: String? = null
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(node),
ourNode = null,
channelSet = channelSet,
// Simulates re-opening the dialog after geofence box authoring with a previously-picked recipient.
initialContactKey = "1${NodeAddress.ID_BROADCAST}",
onSend = { _, contactKey -> sentContactKey = contactKey },
onDelete = {},
onDismissRequest = {},
)
}
onNodeWithText("Secondary").assertIsDisplayed()
onNodeWithText(getString(Res.string.send)).performClick()
assertEquals("1${NodeAddress.ID_BROADCAST}", sentContactKey)
}
@Test
fun filteringRecipientPickerNarrowsNodesButKeepsChannelsVisible() = runComposeUiTest {
val bob = TestDataFactory.createTestNode(num = 43, userId = "!b1c2d3e4", longName = "Bob", shortName = "B")
setContent {
EditWaypointDialog(
waypoint = waypoint,
displayUnits = DisplayUnits.METRIC,
nodes = listOf(node, bob),
ourNode = null,
channelSet = channelSet,
onSend = { _, _ -> },
onDelete = {},
onDismissRequest = {},
)
}
onNodeWithText("Primary").performClick()
onNodeWithText("Alice").assertIsDisplayed()
onNodeWithText("Bob").assertIsDisplayed()
onNodeWithText(getString(Res.string.node_filter_placeholder)).performTextInput("Bob")
// Filtering by node name narrows the node list. "Bob" now matches two elements — the filter field's own
// typed-in value, and the surviving node row — so assert the count directly rather than picking an
// arbitrary match by index, which would risk silently checking the wrong element.
onAllNodesWithText("Bob").assertCountEquals(2)
onAllNodesWithText("Alice").assertCountEquals(0)
// ...but channel rows are never filtered out. "Primary" also matches the (now-covered) "Send to" button
// behind the dialog, so likewise assert the count (button + picker row) instead of an arbitrary index.
onAllNodesWithText("Primary").assertCountEquals(2)
onNodeWithText("Secondary").assertIsDisplayed()
}
}

View File

@@ -55,6 +55,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -74,7 +75,11 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.model.ContactKey
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.geofence.GeofenceRadiusPresets
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
@@ -97,19 +102,69 @@ import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.resources.waypoint_recipient_broadcast
import org.meshtastic.core.resources.waypoint_recipient_channel
import org.meshtastic.core.resources.waypoint_send_to
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.Lock
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
/** Contact key for the broadcast destination on the primary channel — the historical default for waypoints. */
private val BROADCAST_CONTACT_KEY = ContactKey.broadcast(0).value
/**
* Number of selectable channel slots in [channelSet]: at least 1, so a channel-less/disconnected state still offers
* Broadcast.
*/
internal fun channelCount(channelSet: ChannelSet?): Int = channelSet?.settings?.size?.takeIf { it > 0 } ?: 1
/**
* Display name for channel [index] in [channelSet]: its configured name, or "Broadcast" (index 0) / "Channel N" as a
* fallback.
*/
@Composable
internal fun channelLabel(channelSet: ChannelSet?, index: Int): String {
val configuredName = channelSet?.getChannel(index)?.name
return configuredName
?: if (index == 0) {
stringResource(Res.string.waypoint_recipient_broadcast)
} else {
stringResource(Res.string.waypoint_recipient_channel, index)
}
}
/**
* Display name for the recipient identified by [contactKey]: the destination channel's configured name (including the
* primary channel, e.g. "LongFast" — falling back to "Broadcast" only when no channel info is available at all, such as
* while disconnected), a DM node's long/short name, or — if that node is no longer in [nodes] — its raw id, so a stale
* selection never misreports itself as "Broadcast".
*/
@Composable
private fun recipientLabel(contactKey: String, nodes: List<Node>, ourNode: Node?, channelSet: ChannelSet?): String {
val parsed = ContactKey(contactKey)
return if (parsed.address is NodeAddress.Broadcast) {
channelLabel(channelSet, parsed.channel)
} else {
val node = nodes.find { dmContactKey(it, ourNode) == contactKey }
node?.user?.long_name?.takeIf { it.isNotBlank() }
?: node?.user?.short_name?.takeIf { it.isNotBlank() }
?: (parsed.address as? NodeAddress.ById)?.id
?: stringResource(Res.string.waypoint_recipient_broadcast)
}
}
/**
* Shared waypoint editor used by both the google and fdroid map flavors (DRY — replaces the two drifted per-flavor
* copies). Map-engine-specific concerns stay outside: drawing the box overlay and the drag-to-define gesture are
* triggered via [onBeginBoxAuthoring], which hands the current draft back to the flavor's map so the user can define
* the bounding box there; the flavor re-opens this dialog with the drawn box applied.
* triggered via [onBeginBoxAuthoring], which hands the current draft *and the selected recipient* back to the flavor's
* map so the user can define the bounding box there; the flavor re-opens this dialog (passing the same recipient back
* as [initialContactKey]) with the drawn box applied — otherwise the recipient choice would silently reset to Broadcast
* every time the user draws a geofence area.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
@@ -117,17 +172,23 @@ import kotlin.time.Duration.Companion.hours
fun EditWaypointDialog(
waypoint: Waypoint,
displayUnits: DisplayUnits,
onSend: (Waypoint) -> Unit,
nodes: List<Node>,
ourNode: Node?,
channelSet: ChannelSet?,
onSend: (Waypoint, contactKey: String) -> Unit,
onDelete: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
onBeginBoxAuthoring: (Waypoint) -> Unit = {},
initialContactKey: String = BROADCAST_CONTACT_KEY,
onBeginBoxAuthoring: (Waypoint, contactKey: String) -> Unit = { _, _ -> },
) {
var waypointInput by remember { mutableStateOf(waypoint) }
var waypointInput by remember(waypoint.id) { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
var showRecipientPicker by rememberSaveable { mutableStateOf(false) }
var selectedContactKey by rememberSaveable(waypoint.id) { mutableStateOf(initialContactKey) }
val context = LocalContext.current
val tz = systemTimeZone
@@ -210,6 +271,17 @@ fun EditWaypointDialog(
maxLines = 3,
)
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.waypoint_send_to))
TextButton(onClick = { showRecipientPicker = true }) {
Text(recipientLabel(selectedContactKey, nodes, ourNode, channelSet))
}
}
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -371,7 +443,7 @@ fun EditWaypointDialog(
waypoint = waypointInput,
displayUnits = displayUnits,
onWaypointChange = { waypointInput = it },
onBeginBoxAuthoring = { onBeginBoxAuthoring(waypointInput) },
onBeginBoxAuthoring = { onBeginBoxAuthoring(waypointInput, selectedContactKey) },
)
}
},
@@ -389,7 +461,10 @@ fun EditWaypointDialog(
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
Text(stringResource(Res.string.cancel))
}
Button(onClick = { onSend(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) {
Button(
onClick = { onSend(waypointInput, selectedContactKey) },
enabled = (waypointInput.name).isNotBlank(),
) {
Text(stringResource(Res.string.send))
}
}
@@ -403,6 +478,20 @@ fun EditWaypointDialog(
waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0))
}
}
if (showRecipientPicker) {
WaypointRecipientPickerDialog(
nodes = nodes,
ourNode = ourNode,
channelSet = channelSet,
selectedContactKey = selectedContactKey,
onSelect = {
selectedContactKey = it
showRecipientPicker = false
},
onDismissRequest = { showRecipientPicker = false },
)
}
}
/**

View File

@@ -0,0 +1,158 @@
/*
* 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.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ContactKey
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.node_filter_placeholder
import org.meshtastic.core.resources.waypoint_send_to
import org.meshtastic.proto.ChannelSet
/**
* Direct-message contact key for sending to [node]. Uses the PKC channel index only when both ends support it — mirrors
* [org.meshtastic.feature.node.list.NodeListViewModel.getDirectMessageRoute], the app's other DM-key builder —
* otherwise falls back to the node's own channel, matching how a DM would actually route on the mesh.
*/
internal fun dmContactKey(node: Node, ourNode: Node?): String {
val hasPKC = ourNode?.hasPKC == true && node.hasPKC
val channel = if (hasPKC) NodeAddress.PKC_CHANNEL_INDEX else node.channel
return "$channel${node.user.id}"
}
/**
* Nodes whose long or short name contains [filterText] (case-insensitive); all of [nodes] when [filterText] is blank.
*/
private fun filterNodesByName(nodes: List<Node>, filterText: String): List<Node> = if (filterText.isBlank()) {
nodes
} else {
nodes.filter {
it.user.long_name.contains(filterText, ignoreCase = true) ||
it.user.short_name.contains(filterText, ignoreCase = true)
}
}
/** Recipient-picker row label for [node]: long name, short name, or a derived id — never blank. */
private fun nodeRowLabel(node: Node): String = node.user.long_name.takeIf { it.isNotBlank() }
?: node.user.short_name.takeIf { it.isNotBlank() }
?: NodeAddress.numToDefaultId(node.num)
/**
* Lists the primary channel (labelled with its configured name, e.g. "LongFast") plus any secondary channels, then
* every known [nodes] entry — letting the user pick a waypoint destination the same way the Python Meshtastic CLI/API
* can target a channel or a specific node.
*/
@Composable
internal fun WaypointRecipientPickerDialog(
nodes: List<Node>,
ourNode: Node?,
channelSet: ChannelSet?,
selectedContactKey: String,
onSelect: (String) -> Unit,
onDismissRequest: () -> Unit,
) {
val distinctNodes = nodes.distinctBy { it.num }
var filterText by rememberSaveable { mutableStateOf("") }
val filteredNodes = remember(distinctNodes, filterText) { filterNodesByName(distinctNodes, filterText) }
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(Res.string.waypoint_send_to)) },
text = {
Column {
OutlinedTextField(
value = filterText,
onValueChange = { filterText = it },
label = { Text(stringResource(Res.string.node_filter_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.size(8.dp))
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
items(count = channelCount(channelSet), key = { "channel_$it" }) { channelIndex ->
val contactKey = ContactKey.broadcast(channelIndex).value
RecipientRow(
label = channelLabel(channelSet, channelIndex),
selected = selectedContactKey == contactKey,
onClick = { onSelect(contactKey) },
)
}
if (filteredNodes.isNotEmpty()) {
item {
Spacer(modifier = Modifier.size(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.size(8.dp))
}
}
items(filteredNodes, key = { it.num }) { node ->
val contactKey = dmContactKey(node, ourNode)
RecipientRow(
label = nodeRowLabel(node),
selected = selectedContactKey == contactKey,
onClick = { onSelect(contactKey) },
)
}
}
}
},
confirmButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.cancel)) } },
dismissButton = null,
)
}
@Composable
private fun RecipientRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier =
Modifier.fillMaxWidth()
.toggleable(value = selected, role = Role.RadioButton, onValueChange = { onClick() }),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = null)
Spacer(modifier = Modifier.width(8.dp))
Text(label)
}
}

View File

@@ -17,10 +17,12 @@
package org.meshtastic.feature.map
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import org.jetbrains.compose.resources.StringResource
@@ -32,6 +34,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.isBroadcast
import org.meshtastic.core.model.isFromLocal
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.repository.MapPrefs
@@ -40,6 +43,8 @@ import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.nodeSortOption
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.any
import org.meshtastic.core.resources.eight_hours
@@ -67,6 +72,7 @@ open class BaseMapViewModel(
private val radioController: RadioController,
private val radioConfigRepository: RadioConfigRepository,
private val notificationPrefs: NotificationPrefs,
private val uiPrefs: UiPrefs,
) : ViewModel() {
val myNodeInfo = nodeRepository.myNodeInfo
@@ -96,9 +102,13 @@ open class BaseMapViewModel(
.map { it is org.meshtastic.core.model.ConnectionState.Connected }
.stateInWhileSubscribed(initialValue = false)
/**
* Nodes sorted per the user's current Nodes-tab sort preference (re-queried live whenever that preference changes,
* e.g. via the waypoint recipient picker staying in sync with the Nodes tab).
*/
val nodes: StateFlow<List<Node>> =
nodeRepository
.getNodes()
uiPrefs.nodeSortOption
.flatMapLatest { sort -> nodeRepository.getNodes(sort = sort) }
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
.stateInWhileSubscribed(initialValue = emptyList())
@@ -176,16 +186,25 @@ open class BaseMapViewModel(
fun deleteWaypoint(id: Int) =
safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) }
fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${NodeAddress.ID_BROADCAST}") {
/**
* Contact key a waypoint packet was exchanged on: its destination for packets we sent (or broadcasts), otherwise
* its sender — the same derivation as MeshDataHandlerImpl.rememberDataPacket, so edits and expiries route back to
* the conversation the waypoint actually lives in (a raw `to` would self-address a waypoint someone DM'd to us).
*/
fun waypointContactKey(packet: DataPacket): String {
val contact = if (packet.isFromLocal(myNodeNum) || packet.isBroadcast) packet.to else packet.from
return "${packet.channel}$contact"
}
fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${NodeAddress.ID_BROADCAST}"): Job? {
// contactKey: unique contact key filter (channel)+(nodeId)
val parsedKey = ContactKey(contactKey)
val p = DataPacket(parsedKey.addressString, parsedKey.channel, wpt)
if (wpt.id != 0) sendDataPacket(p)
return if (wpt.id != 0) sendDataPacket(p) else null
}
private fun sendDataPacket(p: DataPacket) {
private fun sendDataPacket(p: DataPacket): Job =
safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) }
}
fun generatePacketId(): Int = radioController.generatePacketId()

View File

@@ -23,6 +23,7 @@ import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.UiPrefs
@KoinViewModel
class SharedMapViewModel(
@@ -32,6 +33,7 @@ class SharedMapViewModel(
radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
notificationPrefs: NotificationPrefs,
uiPrefs: UiPrefs,
) : BaseMapViewModel(
mapPrefs,
nodeRepository,
@@ -39,4 +41,5 @@ class SharedMapViewModel(
radioController,
radioConfigRepository,
notificationPrefs,
uiPrefs,
)

View File

@@ -31,12 +31,14 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeUiPrefs
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Waypoint
import kotlin.test.AfterTest
@@ -44,6 +46,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@OptIn(ExperimentalCoroutinesApi::class)
class BaseMapViewModelTest {
@@ -82,6 +85,7 @@ class BaseMapViewModelTest {
radioController = radioController,
radioConfigRepository = radioConfigRepository,
notificationPrefs = FakeNotificationPrefs(),
uiPrefs = FakeUiPrefs(),
)
}
@@ -134,6 +138,66 @@ class BaseMapViewModelTest {
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testNodesResortWhenSortPreferenceChanges() = runTest(testDispatcher) {
val uiPrefs = FakeUiPrefs()
val vm =
BaseMapViewModel(
mapPrefs = mapPrefs,
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = radioController,
radioConfigRepository = radioConfigRepository,
notificationPrefs = FakeNotificationPrefs(),
uiPrefs = uiPrefs,
)
// Alice sorts first alphabetically, but Zoe was heard more recently — the two sort modes disagree, so a
// resort is observable.
val alice = TestDataFactory.createTestNode(num = 1, userId = "!a", longName = "Alice", lastHeard = 100)
val zoe = TestDataFactory.createTestNode(num = 2, userId = "!z", longName = "Zoe", lastHeard = 200)
nodeRepository.setNodes(listOf(alice, zoe))
vm.nodes.test {
// Default sort (LAST_HEARD): most-recently-heard first.
assertEquals(listOf("Zoe", "Alice"), awaitItem().map { it.user.long_name })
// Switching the Nodes-tab sort preference re-sorts the map's node list live, without a new
// subscription.
uiPrefs.setNodeSort(NodeSortOption.ALPHABETICAL.ordinal)
assertEquals(listOf("Alice", "Zoe"), awaitItem().map { it.user.long_name })
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testSendWaypointDefaultsToBroadcast() = runTest(testDispatcher) {
viewModel.sendWaypoint(Waypoint(id = 1, name = "Broadcast Waypoint"))?.join()
val sent = radioController.sentPackets.single()
assertEquals(NodeAddress.ID_BROADCAST, sent.to)
assertEquals(0, sent.channel)
}
@Test
fun testSendWaypointWithContactKeyRoutesToNode() = runTest(testDispatcher) {
val contactKey = "8!a1b2c3d4"
viewModel.sendWaypoint(Waypoint(id = 2, name = "DM Waypoint"), contactKey)?.join()
val sent = radioController.sentPackets.single()
assertEquals("!a1b2c3d4", sent.to)
assertEquals(8, sent.channel)
}
@Test
fun testSendWaypointWithZeroIdDoesNotSend() = runTest(testDispatcher) {
val job = viewModel.sendWaypoint(Waypoint(id = 0, name = "Unsaved Draft"))
assertNull(job)
assertEquals(emptyList(), radioController.sentPackets)
}
@Test
fun testWaypointsIncludeFutureExpirations() = runTest(testDispatcher) {
val now = nowSeconds.toInt()

View File

@@ -16,10 +16,10 @@
*/
package org.meshtastic.feature.node.list
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.nodeSortOption
@Single
@Suppress("TooManyFunctions")
@@ -43,8 +43,7 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open val shouldShowRole = uiPrefs.shouldShowRole
open val shouldShowTelemetry = uiPrefs.shouldShowTelemetry
open val nodeSortOption =
uiPrefs.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
open val nodeSortOption = uiPrefs.nodeSortOption
open fun setNodeSort(option: NodeSortOption) {
uiPrefs.setNodeSort(option.ordinal)