feat/decoupling (#4685)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-03 07:15:28 -06:00 committed by GitHub
parent 40244f8337
commit 2c49db8041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
254 changed files with 5132 additions and 2666 deletions

View file

@ -87,9 +87,8 @@ import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
@ -344,7 +343,7 @@ fun MapView(
LaunchedEffect(selectedWaypointId, waypoints) {
if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) {
waypoints[selectedWaypointId]?.data?.waypoint?.let { pt ->
waypoints[selectedWaypointId]?.waypoint?.let { pt ->
val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
map.controller.setCenter(geoPoint)
map.controller.setZoom(WAYPOINT_ZOOM)
@ -496,7 +495,7 @@ fun MapView(
fun showMarkerLongPressDialog(id: Int) {
performHapticFeedback()
Logger.d { "marker long pressed id=$id" }
val waypoint = waypoints[id]?.data?.waypoint ?: return
val waypoint = waypoints[id]?.waypoint ?: return
// edit only when unlocked or lockedTo myNodeNum
if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
showEditWaypointDialog = waypoint
@ -512,13 +511,13 @@ fun MapView(
}
@Suppress("MagicNumber")
fun MapView.onWaypointChanged(waypoints: Collection<Packet>, selectedWaypointId: Int?): List<MarkerWithLabel> {
fun MapView.onWaypointChanged(waypoints: Collection<DataPacket>, selectedWaypointId: Int?): List<MarkerWithLabel> {
return waypoints.mapNotNull { waypoint ->
val pt = waypoint.data.waypoint ?: return@mapNotNull null
val pt = waypoint.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else ""
val time = DateFormatter.formatDateTime(waypoint.received_time)
val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt())
val time = DateFormatter.formatDateTime(waypoint.time)
val label = (pt.name ?: "") + " " + formatAgo((waypoint.time / 1000).toInt())
val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!))
val now = nowMillis
val expireTimeMillis = (pt.expire ?: 0) * 1000L
@ -530,7 +529,7 @@ fun MapView(
}
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
title = "${pt.name} (${getUsername(waypoint.from)}$lock)"
snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr"
position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
if (selectedWaypointId == pt.id) {

View file

@ -23,13 +23,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@ -41,12 +41,12 @@ class MapViewModel
constructor(
mapPrefs: MapPrefs,
packetRepository: PacketRepository,
private val nodeRepository: NodeRepository,
serviceRepository: ServiceRepository,
override val nodeRepository: NodeRepository,
radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
buildConfigProvider: BuildConfigProvider,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) {
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute<MapRoutes.Map>().waypointId)
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()

View file

@ -98,7 +98,7 @@ import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
@ -272,7 +272,7 @@ fun MapView(
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint }
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
val tracerouteSelection =

View file

@ -45,14 +45,14 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.data.repository.CustomTileProviderRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.Config
import java.io.File
@ -86,11 +86,11 @@ constructor(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
radioController: RadioController,
private val customTileProviderRepository: CustomTileProviderRepository,
uiPreferencesDataSource: UiPreferencesDataSource,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) {
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute<MapRoutes.Map>().waypointId)
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
@ -344,7 +344,7 @@ constructor(
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(wpId) }
wpMap[wpId]?.let { packet ->
val waypoint = packet.data.waypoint!!
val waypoint = packet.waypoint!!
val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
}
@ -643,6 +643,9 @@ constructor(
super.onCleared()
(currentTileProvider as? MBTilesProvider)?.close()
}
override fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
}
enum class LayerType {

View file

@ -30,7 +30,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
@Composable

View file

@ -18,7 +18,7 @@ package org.meshtastic.feature.map.model
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Node
data class NodeClusterItem(
val node: Node,

View file

@ -16,10 +16,8 @@
*/
package org.meshtastic.feature.map
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -29,60 +27,45 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.any
import org.meshtastic.core.resources.eight_hours
import org.meshtastic.core.resources.one_day
import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
@Suppress("MagicNumber")
sealed class LastHeardFilter(val seconds: Long, val label: StringResource) {
data object Any : LastHeardFilter(0L, Res.string.any)
data object OneHour : LastHeardFilter(TimeConstants.ONE_HOUR.inWholeSeconds, Res.string.one_hour)
data object EightHours : LastHeardFilter(TimeConstants.EIGHT_HOURS.inWholeSeconds, Res.string.eight_hours)
data object OneDay : LastHeardFilter(TimeConstants.ONE_DAY.inWholeSeconds, Res.string.one_day)
data object TwoDays : LastHeardFilter(TimeConstants.TWO_DAYS.inWholeSeconds, Res.string.two_days)
companion object {
fun fromSeconds(seconds: Long): LastHeardFilter = entries.find { it.seconds == seconds } ?: Any
val entries = listOf(Any, OneHour, EightHours, OneDay, TwoDays)
}
}
@Suppress("TooManyFunctions")
abstract class BaseMapViewModel(
protected val mapPrefs: MapPrefs,
private val nodeRepository: NodeRepository,
protected open val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
private val serviceRepository: ServiceRepository,
private val radioController: RadioController,
) : ViewModel() {
val myNodeInfo = nodeRepository.myNodeInfo
val ourNodeInfo = nodeRepository.ourNodeInfo
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
val myId = nodeRepository.myId
val isConnected =
radioController.connectionState
.map { it is org.meshtastic.core.model.ConnectionState.Connected }
.stateInWhileSubscribed(initialValue = false)
val nodes: StateFlow<List<Node>> =
nodeRepository
.getNodes()
@ -94,79 +77,66 @@ abstract class BaseMapViewModel(
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
.stateInWhileSubscribed(initialValue = emptyList())
val waypoints: StateFlow<Map<Int, Packet>> =
val waypoints: StateFlow<Map<Int, DataPacket>> =
packetRepository
.getWaypoints()
.mapLatest { list ->
list
.associateBy { packet -> packet.data.waypoint!!.id }
.associateBy { packet -> packet.waypoint!!.id }
.filterValues {
val expire = it.data.waypoint!!.expire ?: 0
val expire = it.waypoint?.expire ?: 0
expire == 0 || expire.toLong() > nowSeconds
}
}
.stateInWhileSubscribed(initialValue = emptyMap())
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites)
private val showWaypointsOnMap = MutableStateFlow(mapPrefs.showWaypointsOnMap)
private val showPrecisionCircleOnMap = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap)
private val lastHeardFilter = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter))
private val lastHeardTrackFilter = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter))
fun setLastHeardFilter(filter: LastHeardFilter) {
mapPrefs.lastHeardFilter = filter.seconds
lastHeardFilter.value = filter
}
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
mapPrefs.lastHeardTrackFilter = filter.seconds
lastHeardTrackFilter.value = filter
}
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
fun getNodeByNum(nodeNum: Int): Node? = nodeRepository.nodeDBbyNum.value[nodeNum]
open fun getUser(userId: String?): User = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
fun getUser(nodeNum: Int): User = nodeRepository.getUser(nodeNum)
fun getNodeOrFallback(nodeNum: Int): Node = getNodeByNum(nodeNum) ?: Node(num = nodeNum, user = getUser(nodeNum))
val isConnected =
serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
val showOnlyFavoritesOnMap = showOnlyFavorites
fun toggleOnlyFavorites() {
val current = showOnlyFavorites.value
mapPrefs.showOnlyFavorites = !current
showOnlyFavorites.value = !current
val newValue = !showOnlyFavorites.value
showOnlyFavorites.value = newValue
mapPrefs.showOnlyFavorites = newValue
}
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap)
val showWaypointsOnMap = showWaypoints
fun toggleShowWaypointsOnMap() {
val current = showWaypointsOnMap.value
mapPrefs.showWaypointsOnMap = !current
showWaypointsOnMap.value = !current
val newValue = !showWaypoints.value
showWaypoints.value = newValue
mapPrefs.showWaypointsOnMap = newValue
}
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap)
val showPrecisionCircleOnMap = showPrecisionCircle
fun toggleShowPrecisionCircleOnMap() {
val current = showPrecisionCircleOnMap.value
mapPrefs.showPrecisionCircleOnMap = !current
showPrecisionCircleOnMap.value = !current
val newValue = !showPrecisionCircle.value
showPrecisionCircle.value = newValue
mapPrefs.showPrecisionCircleOnMap = newValue
}
fun generatePacketId(): Int? {
return try {
serviceRepository.meshService?.packetId
} catch (ex: RemoteException) {
Logger.e { "RemoteException: ${ex.message}" }
return null
}
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter))
val lastHeardFilter = lastHeardFilterValue
fun setLastHeardFilter(filter: LastHeardFilter) {
lastHeardFilterValue.value = filter
mapPrefs.lastHeardFilter = filter.seconds
}
private val lastHeardTrackFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter))
val lastHeardTrackFilter = lastHeardTrackFilterValue
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
lastHeardTrackFilterValue.value = filter
mapPrefs.lastHeardTrackFilter = filter.seconds
}
abstract fun getUser(userId: String?): org.meshtastic.proto.User
fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum)
fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) }
fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
@ -179,13 +149,11 @@ abstract class BaseMapViewModel(
}
private fun sendDataPacket(p: DataPacket) {
try {
serviceRepository.meshService?.send(p)
} catch (ex: RemoteException) {
Logger.e { "Send DataPacket error: ${ex.message}" }
}
viewModelScope.launch(Dispatchers.IO) { radioController.sendMessage(p) }
}
fun generatePacketId(): Int = radioController.getPacketId()
data class MapFilterState(
val onlyFavorites: Boolean,
val showWaypoints: Boolean,
@ -259,3 +227,17 @@ fun BaseMapViewModel.tracerouteNodeSelection(
nodeLookup = nodesForLookup.associateBy { it.num },
)
}
@Suppress("MagicNumber")
enum class LastHeardFilter(val label: StringResource, val seconds: Long) {
Any(Res.string.any, 0L),
OneHour(Res.string.one_hour, 3600L),
EightHours(Res.string.eight_hours, 28800L),
OneDay(Res.string.one_day, 86400L),
TwoDays(Res.string.two_days, 172800L),
;
companion object {
fun fromSeconds(seconds: Long): LastHeardFilter = entries.find { it.seconds == seconds } ?: Any
}
}

View file

@ -29,10 +29,10 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.CustomTileSource

View file

@ -40,14 +40,14 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.data.repository.CustomTileProviderRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.robolectric.RobolectricTestRunner
@OptIn(ExperimentalCoroutinesApi::class)
@ -60,7 +60,7 @@ class MapViewModelTest {
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
private val packetRepository = mockk<PacketRepository>(relaxed = true)
private val radioConfigRepository = mockk<RadioConfigRepository>(relaxed = true)
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
private val radioController = mockk<RadioController>(relaxed = true)
private val customTileProviderRepository = mockk<CustomTileProviderRepository>(relaxed = true)
private val uiPreferencesDataSource = mockk<UiPreferencesDataSource>(relaxed = true)
private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null))
@ -81,7 +81,7 @@ class MapViewModelTest {
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
every { nodeRepository.getNodes() } returns flowOf(emptyList())
every { packetRepository.getWaypoints() } returns flowOf(emptyList())
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
viewModel =
MapViewModel(
@ -91,7 +91,7 @@ class MapViewModelTest {
nodeRepository,
packetRepository,
radioConfigRepository,
serviceRepository,
radioController,
customTileProviderRepository,
uiPreferencesDataSource,
savedStateHandle,