From 8f3bf001cc92145e51cc24f4bb8fc73198406114 Mon Sep 17 00:00:00 2001 From: Joey Leake <61753229+joeyleake@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:17:30 -0400 Subject: [PATCH] feat(map): send a waypoint as a DM or to a specific channel (#6218) --- .skills/compose-ui/strings-index.txt | 4 + .../kotlin/org/meshtastic/app/map/MapView.kt | 32 ++- .../org/meshtastic/app/map/MapViewModel.kt | 3 + .../kotlin/org/meshtastic/app/map/MapView.kt | 34 ++- .../org/meshtastic/app/map/MapViewModel.kt | 1 + .../meshtastic/core/model/NodeSortOption.kt | 6 + .../core/repository/AppPreferences.kt | 7 + .../composeResources/values/strings.xml | 4 + feature/map/build.gradle.kts | 11 + .../map/component/EditWaypointDialogTest.kt | 252 ++++++++++++++++++ .../map/component/EditWaypointDialog.kt | 103 ++++++- .../map/component/WaypointRecipientPicker.kt | 158 +++++++++++ .../feature/map/BaseMapViewModel.kt | 31 ++- .../feature/map/SharedMapViewModel.kt | 3 + .../feature/map/BaseMapViewModelTest.kt | 64 +++++ .../node/list/NodeFilterPreferences.kt | 5 +- 16 files changed, 693 insertions(+), 25 deletions(-) create mode 100644 feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/component/EditWaypointDialogTest.kt create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointRecipientPicker.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index f0821be06..86f866cd9 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 155f64572..1f39b552c 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -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(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(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 }, diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index 56d247d82..bbfcb37c4 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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("waypointId")) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index e91383c37..def972c55 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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(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(null) } var boxAuthoringFirstCorner by remember { mutableStateOf(null) } var boxAuthoringSecondCorner by remember { mutableStateOf(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 { diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 5015ef00c..c8b8b090f 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -97,6 +97,7 @@ class MapViewModel( radioController, radioConfigRepository, notificationPrefs, + uiPrefs, ) { private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt index 656da0cc3..3e2b65421 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt @@ -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 } + } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index a84cac27d..09201a667 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -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 + get() = nodeSort.map { NodeSortOption.fromOrdinal(it) } + /** Reactive interface for notification preferences. */ interface NotificationPrefs { val messagesEnabled: StateFlow diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 2550de7df..bf5f6194c 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1753,10 +1753,14 @@ Wait for Bluetooth duration Wake on tap or motion Warning + Delete waypoint? Edit waypoint New waypoint Received waypoint: %1$s + Broadcast (all nodes) + Channel %1$d + Send to Weight WiFi Options diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 63ce33a72..d6dfea310 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -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) + } + } } } diff --git a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/component/EditWaypointDialogTest.kt b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/component/EditWaypointDialogTest.kt new file mode 100644 index 000000000..2ded8f037 --- /dev/null +++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/component/EditWaypointDialogTest.kt @@ -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 . + */ +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() + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index 49dfeaffc..deb3895c7 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -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, 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, + 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 }, + ) + } } /** diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointRecipientPicker.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointRecipientPicker.kt new file mode 100644 index 000000000..73a6572aa --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointRecipientPicker.kt @@ -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 . + */ +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, filterText: String): List = 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, + 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) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index aabba7f83..85a5761fe 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -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> = - 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() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt index 482e7702f..5e5f533e6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt @@ -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, ) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index 909c3bcb2..b26092dcd 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -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() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt index b3568e286..cf4af287c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt @@ -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)