mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-13 14:52:12 -04:00
feat(map): send a waypoint as a DM or to a specific channel (#6218)
This commit is contained in:
4
.skills/compose-ui/strings-index.txt
generated
4
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -97,6 +97,7 @@ class MapViewModel(
|
||||
radioController,
|
||||
radioConfigRepository,
|
||||
notificationPrefs,
|
||||
uiPrefs,
|
||||
) {
|
||||
|
||||
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user