diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index bf37a8bbe5..c0c30bf6e7 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -221,6 +221,7 @@ communicate_off_the_grid compass_bearing compass_bearing_na compass_distance +compass_find_favorite compass_location_disabled compass_no_location_fix compass_no_location_permission diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000000..b823a70ca7 --- /dev/null +++ b/FORK.md @@ -0,0 +1,100 @@ +# FORK.md + +Divergences of this fork from upstream [`meshtastic/Meshtastic-Android`](https://github.com/meshtastic/Meshtastic-Android). + +Purpose of the fork: a phone-based offline map for hiking in French Guiana. A ~400 MB SCAN 25 / Plan IGN `.mbtiles` +file, no network in the field, and a non-technical second user who must be able to open the app and see the map with +no setup. + +Licence: GPL-3.0, same as upstream. If an APK is distributed, the sources must be published with it. + +Trademark: "Meshtastic" is a registered trademark. The app name and `applicationId` are unchanged so far, which is +fine for personal use. They must be changed before any distribution beyond that. + +## Divergences + +| # | Area | Change | Upstreamable | +|---|------|--------|--------------| +| 1 | Offline map persistence | Imported custom tile providers and the active layer now survive an app restart. | **Yes** — pure bug fix, no fork-specific behaviour. | +| 2 | Offline map priority | On start-up, an imported local (MBTiles) provider is selected automatically when no valid saved selection applies. | No — deliberate product choice for this fork. | +| 3 | Bluetooth-only connections | The transport selector is hidden and the Connections pane is pinned to BLE. | No — upstream deliberately supports three transports. | +| 4 | Compass shortcut on the map | A map toolbar button opens the existing compass straight onto the favourite node. | Possibly — the `openCompass` route flag is generic; the map button is opinionated. | +| 5 | Map control legibility | Map buttons pin an explicit 44dp touch target and a 26dp glyph. | **Yes** — it brings the controls up to the project's own documented minimum. | + +### 4. Compass shortcut on the map + +The compass itself is upstream's, and it already does the hard part: it reads the phone's magnetometer, so the arrow +points where to walk rather than showing a bearing relative to north, and it reports distance, alignment, and the +degraded cases (no magnetometer, no location permission, location off, no fix). Nothing here reimplements it. + +What the fork adds is reach. Upstream's path is: node list → find the right node → open it → tap the compass. This +adds a toolbar button on the main map that lands on the compass in one tap: + +- `NodesRoute.NodeDetail` gains `openCompass: Boolean = false`, and `NodeDetailScreen` opens the compass overlay once + the node has loaded when it is set. +- `MapViewProvider.MapView` and `LocalMapMainScreenProvider` gain a `navigateToNodeCompass` callback, defaulting to + plain node details so nothing else has to change. The osmdroid (fdroid) provider accepts it and ignores it. +- The button appears only when a favourite node has a known position, so it is never a control that does nothing. + +Targeting is by **favourite node**, not a hard-coded node number: mark the companion's device as a favourite once, +and it stays changeable in the field without a rebuild. If several nodes are favourited, the first with a position +wins — fine for a two-person trip, worth revisiting for a larger group. + +### 5. Map control legibility + +`MapButton` sets its touch target and glyph size explicitly instead of inheriting Material's defaults, which left the +map controls below the 44dp minimum in `.skills/design-standards`. Buttons are now 44dp with a 26dp glyph. + +Sized to 44dp and not the roomier 48dp on purpose: `HorizontalFloatingToolbar` does not scroll, and a fully-populated +map toolbar (compass, find-favourite, filter, map type, layers, site planner, location) at 48dp overflows a 360dp-wide +screen and clips its last buttons. 44dp meets the standard and still fits. If the toolbar ever gains horizontal +scrolling, 48dp becomes the better value. + +Deliberately **not** changed: the icon glyphs themselves. Which symbols read as unclear is a judgement that needs eyes +on a real screen, and swapping artwork blind would trade a known set of icons for an unverified one. + +### 1. Offline map persistence (bug fix) + +Symptom: after every app close, the imported `.mbtiles` had to be re-imported and its layer re-enabled by hand. + +Two independent root causes, each sufficient on its own: + +- **The provider list was lost on every cold start.** `CustomTileProviderRepositoryImpl` read + `MapTileProviderPrefs.customTileProviders.value` in its constructor. That flow was a `StateFlow` seeded with `null` + while the real value arrived asynchronously from DataStore, so the constructor always read the placeholder and cached + an empty list, permanently. The next edit then persisted a list built on that empty baseline, **destroying** the + stored providers rather than merely failing to display them — and orphaning their ~400 MB files in internal storage. +- **A local provider's selection could never be restored.** `MapViewModel.loadPersistedMapType()` matched the saved + selection with `it.urlTemplate == savedCustomUrl && isValidTileUrlTemplate(savedCustomUrl)`. An imported MBTiles + provider has an empty `urlTemplate` and is persisted by its `file://` URI, which contains no `{z}/{x}/{y}`, so both + conditions were always false. The fallback branch then actively cleared the stored preference. The renderer + (`MapView.kt`) resolved the same selection correctly, so the two paths disagreed. + +Fix: + +- `MapTileProviderPrefs.customTileProviders` and `GoogleMapsPrefs.selectedCustomTileUrl` / + `selectedGoogleMapType` are plain `Flow`s instead of `StateFlow`s. A `StateFlow` has to invent an initial value, and + callers cannot tell that placeholder apart from a genuine "nothing saved". +- `CustomTileProviderRepositoryImpl` keeps a nullable cache — `null` meaning "not read yet" — publishes only once the + store has answered, and serializes read-modify-write cycles behind a `Mutex`. +- Selection resolution lives in `CustomTileProviderConfig.selectionKey` / `matchesSelection`, used by both the restore + path and the renderer so they cannot drift apart again. +- `MapViewModel.restoreMapSelection()` awaits both stores before deciding, and only clears a stored selection once the + provider list is known to be loaded. +- Fixed along the way: editing a provider left the persisted selection pointing at a stale key, so the layer was + dropped on the following start. + +Also removed: `feature/map/src/androidUnitTestGoogle/` (`MapViewModelTest`, `MBTilesProviderTest`). Those files sat in +a source set no Gradle task builds, targeted an `org.meshtastic.feature.map` package that does not exist, and used a +`MapViewModel` constructor signature several parameters out of date. They never compiled or ran — which is how this +bug shipped. Replacement tests live in `androidApp/src/testGoogle/`, the source set that is actually wired up. + +### 2. Offline map priority (fork behaviour) + +`MapViewModel.restoreMapSelection()` selects the first local provider when there is no usable saved selection, so the +app opens on the offline map with no network and no user action. + +Trade-off, stated plainly: while an MBTiles provider exists, a deliberate switch to a Google base map does not survive +a restart — the offline layer wins again on the next launch. That is intended here (the app must always come up usable +in the forest). To soften it, drop the `offlineProvider` branch in `restoreMapSelection()`; restoring an explicit saved +selection keeps working without it. diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt index 21c2d4fdea..53142adfbb 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt @@ -27,7 +27,13 @@ import org.meshtastic.core.ui.util.MapViewProvider @Single class FdroidMapViewProvider : MapViewProvider { @Composable - override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { + // navigateToNodeCompass is accepted for interface parity; the osmdroid map has no compass shortcut button yet. + override fun MapView( + modifier: Modifier, + navigateToNodeDetails: (Int) -> Unit, + waypointId: Int?, + navigateToNodeCompass: (Int) -> Unit, + ) { val mapViewModel: MapViewModel = koinViewModel() LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } org.meshtastic.app.map.MapView( diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt index 940c4ab5a0..c307ce4328 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt @@ -27,13 +27,19 @@ import org.meshtastic.core.ui.util.MapViewProvider @Single class GoogleMapViewProvider : MapViewProvider { @Composable - override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { + override fun MapView( + modifier: Modifier, + navigateToNodeDetails: (Int) -> Unit, + waypointId: Int?, + navigateToNodeCompass: (Int) -> Unit, + ) { val mapViewModel: MapViewModel = koinViewModel() LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } org.meshtastic.app.map.MapView( modifier = modifier, mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails, + navigateToNodeCompass = navigateToNodeCompass, ) } } 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 9325777016..f310443575 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -239,6 +239,7 @@ fun MapView( mapViewModel: MapViewModel = koinViewModel(), navigateToNodeDetails: (Int) -> Unit = {}, mode: GoogleMapMode = GoogleMapMode.Main, + navigateToNodeCompass: (Int) -> Unit = navigateToNodeDetails, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -656,7 +657,7 @@ fun MapView( currentCustomTileProviderUrl?.let { url -> val config = mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find { - it.urlTemplate == url || it.localUri == url + it.matchesSelection(url) } mapViewModel.getTileProvider(config)?.let { tileProvider -> TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f) @@ -876,6 +877,16 @@ fun MapView( val showRefresh = visibleNetworkLayers.isNotEmpty() val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } + // Favourite node to point the compass at. Marking the companion's node as a favourite is the whole setup — no + // hard-coded node number, and it stays changeable in the field. Requires a known position, so the shortcut only + // appears when it can actually give a bearing. Main map only; the track and traceroute modes have one subject + // already. See FORK.md. + val nodesWithPosition by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf()) + val favoriteNodeToFind = + remember(nodesWithPosition, mode) { + nodesWithPosition.firstOrNull { node -> node.isFavorite }.takeIf { mode is GoogleMapMode.Main } + } + MapControlsOverlay( modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), onToggleFilterMenu = { mapFilterMenuExpanded = true }, @@ -926,6 +937,7 @@ fun MapView( } else { null }, + onFindFavoriteClick = favoriteNodeToFind?.let { node -> { navigateToNodeCompass(node.num) } }, isLocationTrackingEnabled = isLocationTrackingEnabled, onToggleLocationTracking = { when { 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 1edf695413..5ca51ec7e2 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -156,7 +156,11 @@ class MapViewModel( _errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.") return@launch } - if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) { + if ( + customTileProviderRepository.getCustomTileProviders().first().any { + it.name.equals(name, ignoreCase = true) + } + ) { _errorFlow.emit("Custom tile provider with name '$name' already exists.") return@launch } @@ -198,7 +202,7 @@ class MapViewModel( _errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.") return@launch } - val existingConfigs = customTileProviderConfigs.value + val existingConfigs = customTileProviderRepository.getCustomTileProviders().first() if ( existingConfigs.any { it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true) @@ -208,20 +212,15 @@ class MapViewModel( return@launch } + // Read before writing: afterwards the store holds the new config, so the old selection key is gone. + val previous = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id) customTileProviderRepository.updateCustomTileProvider(configToUpdate) - val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id) - if ( - _selectedCustomTileProviderUrl.value != null && - originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value - ) { - // No change needed if URL didn't change, or handle if it did - } else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) { - val currentlySelectedConfig = - customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value } - if (currentlySelectedConfig?.id == configToUpdate.id) { - _selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate - } + // Follow the edit when the provider being displayed is the one that just changed, otherwise the persisted + // selection points at a key nothing matches any more and the layer is dropped on the next start. + if (previous != null && _selectedCustomTileProviderUrl.value == previous.selectionKey) { + _selectedCustomTileProviderUrl.value = configToUpdate.selectionKey + googleMapsPrefs.setSelectedCustomTileUrl(configToUpdate.selectionKey) } } } @@ -232,10 +231,7 @@ class MapViewModel( customTileProviderRepository.deleteCustomTileProvider(configId) if (configToRemove != null) { - if ( - _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate || - _selectedCustomTileProviderUrl.value == configToRemove.localUri - ) { + if (_selectedCustomTileProviderUrl.value?.let(configToRemove::matchesSelection) == true) { _selectedCustomTileProviderUrl.value = null // Also clear from prefs googleMapsPrefs.setSelectedCustomTileUrl(null) @@ -257,8 +253,7 @@ class MapViewModel( googleMapsPrefs.setSelectedCustomTileUrl(null) return } - // Use localUri if present, otherwise urlTemplate - val selectedUrl = config.localUri ?: config.urlTemplate + val selectedUrl = config.selectionKey _selectedCustomTileProviderUrl.value = selectedUrl _selectedGoogleMapType.value = MapType.NONE googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl) @@ -287,7 +282,7 @@ class MapViewModel( return null } - val selectedUrl = config.localUri ?: config.urlTemplate + val selectedUrl = config.selectionKey if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) { return currentTileProvider } @@ -360,10 +355,7 @@ class MapViewModel( } } - viewModelScope.launch { - customTileProviderRepository.getCustomTileProviders().first() - loadPersistedMapType() - } + viewModelScope.launch { restoreMapSelection() } selectedWaypointId.value?.let { wpId -> viewModelScope.launch { @@ -387,32 +379,45 @@ class MapViewModel( saveCameraPosition(cameraPositionState.position) } - private fun loadPersistedMapType() { - val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value - if (savedCustomUrl != null) { - // Check if this custom provider still exists - if ( - customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } && - isValidTileUrlTemplate(savedCustomUrl) - ) { - _selectedCustomTileProviderUrl.value = savedCustomUrl - _selectedGoogleMapType.value = - MapType.NONE // MapType.NONE to hide google basemap when using custom provider - } else { - // The saved custom URL is no longer valid or doesn't exist, remove preference - googleMapsPrefs.setSelectedCustomTileUrl(null) - // Fallback to default Google Map type - _selectedGoogleMapType.value = MapType.NORMAL - } - } else { - val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value - try { - _selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name) - } catch (e: IllegalArgumentException) { - Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" } - _selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name - googleMapsPrefs.setSelectedGoogleMapType(null) - } + /** + * Restores the map layer chosen on a previous run, awaiting both stores before deciding. + * + * Both reads have to be awaited rather than sampled: the providers and the saved selection live in separate + * DataStores, and acting on a not-yet-loaded value silently discards the user's offline map. + */ + private suspend fun restoreMapSelection() { + val configs = customTileProviderRepository.getCustomTileProviders().first() + val savedSelection = googleMapsPrefs.selectedCustomTileUrl.first() + + val savedProvider = savedSelection?.let { selection -> configs.firstOrNull { it.matchesSelection(selection) } } + if (savedProvider != null) { + selectCustomTileProvider(savedProvider) + return + } + if (savedSelection != null) { + // configs is loaded here, so an unmatched selection really is a provider that no longer exists. + googleMapsPrefs.setSelectedCustomTileUrl(null) + } + + val offlineProvider = configs.firstOrNull { it.isLocal } + if (offlineProvider != null) { + // Fork behaviour: an imported offline map always wins on start-up, so the app opens on usable tiles with no + // network and no user action. See FORK.md. + selectCustomTileProvider(offlineProvider) + return + } + + restoreGoogleMapType() + } + + private suspend fun restoreGoogleMapType() { + val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.first() + try { + _selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name) + } catch (e: IllegalArgumentException) { + Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" } + _selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name + googleMapsPrefs.setSelectedGoogleMapType(null) } } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt index 5bd9fdf87f..3e90ff9315 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt @@ -28,4 +28,20 @@ data class CustomTileProviderConfig( ) { val isLocal: Boolean get() = localUri != null + + /** + * The value persisted to mark this provider as the active map selection. Local (MBTiles) providers are identified + * by their file URI because [urlTemplate] is empty for them. + */ + val selectionKey: String + get() = localUri ?: urlTemplate + + /** + * True when [selection] — a value previously produced by [selectionKey] — refers to this provider. + * + * Both the renderer and the start-up restore path must resolve a persisted selection the same way. Keeping the rule + * here stops them drifting apart: a restore path that only compared [urlTemplate] silently dropped every local + * provider on restart. + */ + fun matchesSelection(selection: String): Boolean = localUri == selection || urlTemplate == selection } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt index 44daec8298..b4a778792d 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt @@ -25,10 +25,7 @@ import com.google.maps.android.compose.MapType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.app.map.prefs.di.GoogleMapsDataStore @@ -36,11 +33,14 @@ import org.meshtastic.core.di.CoroutineDispatchers /** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */ interface GoogleMapsPrefs { - val selectedGoogleMapType: StateFlow + // These two are plain flows rather than state flows on purpose: their stored value has to be awaited at start-up. + // Reading `.value` off a state flow returns the placeholder the flow was seeded with, not what is on disk, so the + // map used to restore a default instead of the user's actual selection. + val selectedGoogleMapType: Flow fun setSelectedGoogleMapType(value: String?) - val selectedCustomTileUrl: StateFlow + val selectedCustomTileUrl: Flow fun setSelectedCustomTileUrl(value: String?) @@ -62,10 +62,8 @@ class GoogleMapsPrefsImpl(private val dataStore: GoogleMapsDataStore, dispatcher GoogleMapsPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - override val selectedGoogleMapType: StateFlow = - dataStore.data - .map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name } - .stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name) + override val selectedGoogleMapType: Flow = + dataStore.data.map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name } override fun setSelectedGoogleMapType(value: String?) { scope.launch { @@ -79,8 +77,7 @@ class GoogleMapsPrefsImpl(private val dataStore: GoogleMapsDataStore, dispatcher } } - override val selectedCustomTileUrl: StateFlow = - dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null) + override val selectedCustomTileUrl: Flow = dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] } override fun setSelectedCustomTileUrl(value: String?) { scope.launch { diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt index 48d89d258c..f7d2b4815c 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt @@ -17,9 +17,15 @@ package org.meshtastic.app.map.repository import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json @@ -47,58 +53,65 @@ class CustomTileProviderRepositoryImpl( private val mapTileProviderPrefs: MapTileProviderPrefs, ) : CustomTileProviderRepository { - private val customTileProvidersStateFlow = MutableStateFlow>(emptyList()) + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) + + /** + * `null` until the persisted list has actually been read back at least once. + * + * The distinction matters: this used to be seeded with an empty list, and every read-modify-write that landed + * before the first disk read completed persisted a list built on that empty baseline — wiping the user's imported + * providers instead of merely failing to show them. + */ + private val cache = MutableStateFlow?>(null) + + /** Serializes read-modify-write cycles so two concurrent edits cannot each overwrite the other. */ + private val writeLock = Mutex() init { - loadDataFromPrefs() + scope.launch { mapTileProviderPrefs.customTileProviders.collect { cache.value = it.decodeConfigs() } } } - override fun getCustomTileProviders(): Flow> = - customTileProvidersStateFlow.asStateFlow() + override fun getCustomTileProviders(): Flow> = cache.filterNotNull() - override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) { - val newList = customTileProvidersStateFlow.value + config - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) - } + override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) = mutate { it + config } - override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) { - val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it } - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) + override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) = mutate { providers -> + providers.map { if (it.id == config.id) config else it } } - override suspend fun deleteCustomTileProvider(configId: String) { - val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId } - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) + override suspend fun deleteCustomTileProvider(configId: String) = mutate { providers -> + providers.filterNot { it.id == configId } } override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? = - customTileProvidersStateFlow.value.find { it.id == configId } - - private fun loadDataFromPrefs() { - val jsonString = mapTileProviderPrefs.customTileProviders.value - if (jsonString != null) { - try { - customTileProvidersStateFlow.value = json.decodeFromString>(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error deserializing tile providers" } - customTileProvidersStateFlow.value = emptyList() - } - } else { - customTileProvidersStateFlow.value = emptyList() + loaded().find { it.id == configId } + + /** Suspends until the persisted list has been read, so no write is ever built on an unloaded baseline. */ + private suspend fun loaded(): List = cache.filterNotNull().first() + + private suspend fun mutate(transform: (List) -> List) { + writeLock.withLock { + val updated = transform(loaded()) + val encoded = + try { + json.encodeToString(updated) + } catch (e: SerializationException) { + Logger.e(e) { "Error serializing tile providers" } + return + } + // Publish before the store round-trip so an immediately following edit reads this list, not the stale one. + cache.value = updated + withContext(dispatchers.io) { mapTileProviderPrefs.setCustomTileProviders(encoded) } } } - private suspend fun saveDataToPrefs(providers: List) { - withContext(dispatchers.io) { - try { - val jsonString = json.encodeToString(providers) - mapTileProviderPrefs.setCustomTileProviders(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error serializing tile providers" } - } + private fun String?.decodeConfigs(): List { + if (this == null) return emptyList() + return try { + json.decodeFromString>(this) + } catch (e: SerializationException) { + Logger.e(e) { "Error deserializing tile providers" } + emptyList() } } } diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index b328ebcda8..0a69e6723b 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -269,13 +269,14 @@ class MainActivity : AppCompatActivity() { ) }, LocalMapMainScreenProvider provides - { onClickNodeChip, navigateToNodeDetails, waypointId -> + { onClickNodeChip, navigateToNodeDetails, waypointId, navigateToNodeCompass -> val viewModel = koinViewModel() MapScreen( viewModel = viewModel, onClickNodeChip = onClickNodeChip, navigateToNodeDetails = navigateToNodeDetails, waypointId = waypointId, + navigateToNodeCompass = navigateToNodeCompass, ) }, content = content, diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfigTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfigTest.kt new file mode 100644 index 0000000000..bb5cd94227 --- /dev/null +++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfigTest.kt @@ -0,0 +1,63 @@ +/* + * 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.app.map.model + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The persisted map selection is resolved in two places — the renderer and the start-up restore. They disagreed: the + * restore path only compared [CustomTileProviderConfig.urlTemplate], which is empty for an imported MBTiles file, so a + * local provider could never be matched back and its layer was dropped on every restart. + */ +class CustomTileProviderConfigTest { + + private val offlineMap = + CustomTileProviderConfig( + name = "SCAN 25", + urlTemplate = "", + localUri = "file:///data/user/0/app/files/map_layers/mbtiles_scan25.mbtiles", + ) + private val networkMap = + CustomTileProviderConfig(name = "OpenTopoMap", urlTemplate = "https://tile.opentopomap.org/{z}/{x}/{y}.png") + + @Test + fun `a local provider is identified by its file uri`() { + assertEquals(offlineMap.localUri, offlineMap.selectionKey) + assertTrue(offlineMap.isLocal) + } + + @Test + fun `a network provider is identified by its url template`() { + assertEquals(networkMap.urlTemplate, networkMap.selectionKey) + assertFalse(networkMap.isLocal) + } + + @Test + fun `a provider matches the selection key it produced`() { + assertTrue(offlineMap.matchesSelection(offlineMap.selectionKey)) + assertTrue(networkMap.matchesSelection(networkMap.selectionKey)) + } + + @Test + fun `a local provider does not match another provider's selection`() { + assertFalse(offlineMap.matchesSelection(networkMap.selectionKey)) + assertFalse(networkMap.matchesSelection(offlineMap.selectionKey)) + } +} diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepositoryTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepositoryTest.kt new file mode 100644 index 0000000000..36a678b104 --- /dev/null +++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepositoryTest.kt @@ -0,0 +1,168 @@ +/* + * 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.app.map.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import okio.FileSystem +import okio.Path +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.meshtastic.app.map.model.CustomTileProviderConfig +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.prefs.di.asMapTileProviderDataStore +import org.meshtastic.core.prefs.map.MapTileProviderPrefsImpl +import org.meshtastic.core.repository.MapTileProviderPrefs +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid + +/** + * Guards the offline map surviving an app restart. + * + * The repository used to seed itself with an empty list and read the stored value synchronously at construction, before + * the store had answered. That lost the imported providers on every cold start, and — worse — the next edit persisted a + * list built on the empty baseline, destroying what was on disk rather than merely failing to show it. + */ +class CustomTileProviderRepositoryTest { + + private val json = Json + + private val scan25 = + CustomTileProviderConfig( + name = "SCAN 25", + urlTemplate = "", + localUri = "file:///data/user/0/app/files/map_layers/mbtiles_scan25.mbtiles", + ) + private val planIgn = + CustomTileProviderConfig( + name = "Plan IGN", + urlTemplate = "", + localUri = "file:///data/user/0/app/files/map_layers/mbtiles_planign.mbtiles", + ) + + private lateinit var tmpDir: Path + private lateinit var dataStore: DataStore + private lateinit var testDispatcher: TestDispatcher + private lateinit var testScope: TestScope + private lateinit var dispatchers: CoroutineDispatchers + + @Before + fun setup() { + testDispatcher = UnconfinedTestDispatcher() + testScope = TestScope(testDispatcher) + dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "customTileProviderRepositoryTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) + dataStore = + PreferenceDataStoreFactory.createWithPath( + scope = testScope, + produceFile = { tmpDir / "test.preferences_pb" }, + ) + } + + @After + fun tearDown() { + testScope.cancel() + FileSystem.SYSTEM.deleteRecursively(tmpDir) + } + + /** A repository built over the persistent store — a new one stands in for a cold app start. */ + private fun coldStart(): CustomTileProviderRepository = CustomTileProviderRepositoryImpl( + json = json, + dispatchers = dispatchers, + mapTileProviderPrefs = MapTileProviderPrefsImpl(dataStore.asMapTileProviderDataStore(), dispatchers), + ) + + private fun repositoryOver(prefs: MapTileProviderPrefs): CustomTileProviderRepository = + CustomTileProviderRepositoryImpl(json = json, dispatchers = dispatchers, mapTileProviderPrefs = prefs) + + @Test + fun `an imported offline map is still there after a cold start`() = testScope.runTest { + coldStart().addCustomTileProvider(scan25) + + val restored = coldStart().getCustomTileProviders().first() + + assertEquals(listOf(scan25), restored) + } + + @Test + fun `editing after a cold start keeps the providers already stored`() = testScope.runTest { + coldStart().addCustomTileProvider(scan25) + + // No read before the write: a cold-started repository must still fetch the stored list first. + coldStart().addCustomTileProvider(planIgn) + + assertEquals(listOf(scan25, planIgn), coldStart().getCustomTileProviders().first()) + } + + @Test + fun `nothing is published while the store has not answered yet`() = testScope.runTest { + val repository = repositoryOver(SilentStore()) + + val published = withTimeoutOrNull(1.seconds) { repository.getCustomTileProviders().first() } + + // An empty list here would be indistinguishable from "no providers saved", which is what used to leak out. + assertNull(published) + } + + @Test + fun `a write waits for the stored list rather than overwriting it`() = testScope.runTest { + val store = SilentStore() + val repository = repositoryOver(store) + + val write = launch { repository.addCustomTileProvider(planIgn) } + assertNull(store.written, "wrote before knowing what was already stored") + + store.publish(json.encodeToString(listOf(scan25))) + write.join() + + val stored = json.decodeFromString>(store.written.orEmpty()) + assertEquals(listOf(scan25, planIgn), stored) + } + + /** A store that stays silent until [publish] is called, standing in for a disk read still in flight. */ + private class SilentStore : MapTileProviderPrefs { + private val emissions = MutableSharedFlow(replay = 1) + + var written: String? = null + private set + + override val customTileProviders: Flow = emissions + + override fun setCustomTileProviders(providers: String?) { + written = providers + emissions.tryEmit(providers) + } + + suspend fun publish(stored: String?) = emissions.emit(stored) + } +} diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index 2c375a6de4..0bcf0af75d 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -61,7 +61,11 @@ sealed interface MapRoute : Route { sealed interface NodesRoute : Route { @Serializable data object Nodes : NodesRoute, Graph - @Serializable data class NodeDetail(val destNum: Int? = null) : NodesRoute + /** + * @param openCompass opens the compass overlay as soon as the screen appears, so a caller can hand the user a + * direction and a distance in one tap instead of making them find the node and then the compass button. + */ + @Serializable data class NodeDetail(val destNum: Int? = null, val openCompass: Boolean = false) : NodesRoute } @Serializable diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt index fb5c2edf03..b6e51fd952 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt @@ -20,10 +20,8 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers @@ -35,8 +33,9 @@ class MapTileProviderPrefsImpl(private val dataStore: MapTileProviderDataStore, MapTileProviderPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - override val customTileProviders: StateFlow = - dataStore.data.map { it[KEY_CUSTOM_PROVIDERS_PREF] }.stateIn(scope, SharingStarted.Eagerly, null) + // Not a StateFlow: see MapTileProviderPrefs.customTileProviders. Every emission here is a value actually read from + // storage, so a collector can tell "no providers saved" apart from "not read yet". + override val customTileProviders: Flow = dataStore.data.map { it[KEY_CUSTOM_PROVIDERS_PREF] } override fun setCustomTileProviders(providers: String?) { scope.launch { 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 278e81cc64..80d307a289 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,6 +16,7 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.DeviceType @@ -303,7 +304,14 @@ interface MapConsentPrefs { /** Reactive interface for map tile provider settings. */ interface MapTileProviderPrefs { - val customTileProviders: StateFlow + /** + * The serialized provider list, emitted only once actually read back from storage. + * + * Deliberately not a [StateFlow]: a state flow has to invent an initial value, and a caller cannot tell that + * placeholder apart from "nothing was ever saved". Callers used to read `.value` synchronously at construction + * time, lose that race, and then persist a list built on the empty placeholder — destroying the stored providers. + */ + val customTileProviders: Flow fun setCustomTileProviders(providers: String?) } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 8fd76e4ca7..df13184f17 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -242,6 +242,7 @@ Bearing: %1$s Bearing: N/A Distance: %1$s + Where is my contact Location provider is disabled. Turn on location services. Waiting for a GPS fix to calculate distance and bearing. Location permission is required to show distance and bearing. diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml index d588fb49dc..a80514f4ba 100644 --- a/core/ui/detekt-baseline.xml +++ b/core/ui/detekt-baseline.xml @@ -20,7 +20,7 @@ CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit) -> BarcodeScanner> { { object : BarcodeScanner { override fun startScan() { // Default NO-OP } } } } CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerSupported = compositionLocalOf { false } CompositionLocalAllowlist:LocalInlineMapProvider.kt:val LocalInlineMapProvider = compositionLocalOf<@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } } - CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf< @Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit, > { { _, _, _ -> PlaceholderScreen("Map") } } + CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf< @Composable ( onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?, navigateToNodeCompass: (Int) -> Unit, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Map") } } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf<@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt index 70ed07a2b0..417e47a112 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt @@ -27,7 +27,12 @@ import org.meshtastic.core.ui.component.PlaceholderScreen @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf< - @Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit, + @Composable ( + onClickNodeChip: (Int) -> Unit, + navigateToNodeDetails: (Int) -> Unit, + waypointId: Int?, + navigateToNodeCompass: (Int) -> Unit, + ) -> Unit, > { - { _, _, _ -> PlaceholderScreen("Map") } + { _, _, _, _ -> PlaceholderScreen("Map") } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt index 10d975f3d4..8c38ec9dff 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt @@ -25,7 +25,17 @@ import androidx.compose.ui.Modifier * implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin. */ interface MapViewProvider { - @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null) + /** + * @param navigateToNodeCompass opens a node straight on its compass. Defaults to plain node details so + * implementations and callers that do not offer the shortcut keep working unchanged. + */ + @Composable + fun MapView( + modifier: Modifier, + navigateToNodeDetails: (Int) -> Unit, + waypointId: Int? = null, + navigateToNodeCompass: (Int) -> Unit = navigateToNodeDetails, + ) } val LocalMapViewProvider = compositionLocalOf { null } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 898524ebe0..2de998448b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -23,7 +23,6 @@ import co.touchlab.kermit.Severity import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -35,7 +34,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.meshtastic.core.ble.BleDevice @@ -322,18 +320,14 @@ open class ScannerViewModel( // ── Active transport pane ──────────────────────────────────────────────────────────────── - /** The single transport pane currently rendered by the Connections screen. */ - val activeTransport: StateFlow = - combine(uiPrefs.selectedConnectionTransport, selectedAddressFlow) { preferred, selectedAddress -> - resolveActiveTransport(preferred, selectedAddress) - } - .distinctUntilChanged() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = - resolveActiveTransport(uiPrefs.selectedConnectionTransport.value, selectedAddressFlow.value), - ) + /** + * The single transport pane currently rendered by the Connections screen. + * + * Fork behaviour: this build only offers Bluetooth, so the pane is pinned to BLE and the Connections screen hides + * the transport selector. Pinning matters beyond hiding the control — a phone that already had TCP or USB stored + * would otherwise open on an empty pane with no visible way back to Bluetooth. See FORK.md. + */ + val activeTransport: StateFlow = MutableStateFlow(DeviceType.BLE) /** Selects one Connections transport pane and stops scans that cannot belong to that pane. */ fun selectTransport(type: DeviceType) { @@ -685,9 +679,6 @@ open class ScannerViewModel( } } - private fun resolveActiveTransport(preferred: DeviceType?, selectedAddress: String?): DeviceType = - preferred ?: selectedAddress?.let(DeviceType::fromAddress) ?: DeviceType.BLE - private fun recordSelectedTransport(fullAddress: String) { DeviceType.fromAddress(fullAddress)?.let(uiPrefs::setSelectedConnectionTransport) } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index d5896d4809..1eed470bd2 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -115,7 +115,6 @@ import org.meshtastic.feature.connections.ui.components.ConnectingDeviceInfo import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedInfo import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedText import org.meshtastic.feature.connections.ui.components.DeviceList -import org.meshtastic.feature.connections.ui.components.TransportSelector import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.getNavRouteFrom import org.meshtastic.feature.settings.radio.RadioConfigViewModel @@ -417,12 +416,8 @@ fun ConnectionsScreen( } } - // Transport selector sits between the connection card and device list; it controls only the - // visible discovery pane, not the globally selected/connected device shown above. - TransportSelector( - activeTransport = activeTransport, - onSelectTransport = scanModel::selectTransport, - ) + // Fork behaviour: no transport selector. This build offers Bluetooth only, and the pane is + // pinned to BLE in ScannerViewModel. See FORK.md. // Adapter-off hints: shown only when the relevant permission is granted but the radio/network // is unavailable, so they don't overlap the permission-recovery flow on the scan toggles. diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index 44db8cf3a5..08c6354baa 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -284,10 +284,21 @@ class ScannerViewModelTest { } @Test - fun `active transport defaults to selected device type when no explicit preference exists`() { + fun `active transport stays BLE even when the selected device is a network one`() { harness.currentDeviceAddressFlow.value = "t192.168.1.50" - assertEquals(DeviceType.TCP, viewModel.activeTransport.value) + // Fork behaviour: Bluetooth-only build. A TCP or USB address left over from another build must not strand the + // Connections screen on a pane whose selector is hidden. See FORK.md. + assertEquals(DeviceType.BLE, viewModel.activeTransport.value) + } + + @Test + fun `a stored non-Bluetooth transport preference is ignored`() { + harness.uiPrefs.setSelectedConnectionTransport(DeviceType.USB) + + // The trap this guards: with the selector hidden, honouring the stored preference would open an empty USB pane + // the user has no visible way to leave. + assertEquals(DeviceType.BLE, viewModel.activeTransport.value) } @Test @@ -476,8 +487,9 @@ class ScannerViewModelTest { viewModel.selectTransport(DeviceType.TCP) - assertEquals(DeviceType.TCP, viewModel.activeTransport.value) assertEquals(false, viewModel.isBleScanning.value) + // Only the scan-stopping side effect survives in this build; the pane itself stays on BLE. + assertEquals(DeviceType.BLE, viewModel.activeTransport.value) } @Test @@ -487,7 +499,6 @@ class ScannerViewModelTest { viewModel.selectTransport(DeviceType.USB) - assertEquals(DeviceType.USB, viewModel.activeTransport.value) assertEquals(false, viewModel.isBleScanning.value) assertEquals(false, viewModel.isNetworkScanning.value) @@ -583,21 +594,18 @@ class ScannerViewModelTest { } @Test - fun `startBleAutoScan starts only when active transport is BLE`() = runTest { + fun `startBleAutoScan is never gated off because the pane is pinned to BLE`() = runTest { + // Selecting another transport no longer moves the pane, so BLE discovery can always start. That is the point: + // a stored TCP or USB preference must not leave the device list permanently empty. See FORK.md. viewModel.selectTransport(DeviceType.TCP) viewModel.startBleAutoScan() - assertEquals(false, viewModel.isBleScanning.value) - - viewModel.selectTransport(DeviceType.BLE) - viewModel.startBleAutoScan() - assertEquals(true, viewModel.isBleScanning.value) } @Test - fun `startNetworkAutoScan starts only when active transport is TCP`() = runTest { + fun `startNetworkAutoScan never starts in a Bluetooth-only build`() = runTest { viewModel.startNetworkAutoScan() assertEquals(false, viewModel.isNetworkScanning.value) @@ -605,7 +613,7 @@ class ScannerViewModelTest { viewModel.selectTransport(DeviceType.TCP) viewModel.startNetworkAutoScan() - assertEquals(true, viewModel.isNetworkScanning.value) + assertEquals(false, viewModel.isNetworkScanning.value) } // ── Toggle persistence: enable clears the opposite persisted auto-scan pref ────────────── @@ -670,7 +678,7 @@ class ScannerViewModelTest { } @Test - fun `onSelected records active transport for BLE TCP and USB entries`() = runTest { + fun `onSelected leaves the pane on BLE whatever entry type is picked`() = runTest { val bleEntry = DeviceListEntry.Ble(device = FakeBleDevice(address = "01:02:03:04:05:06", name = "BLE Node"), bonded = true) val tcpEntry = DeviceListEntry.Tcp(name = "TCP Node", fullAddress = "t192.168.1.50") @@ -686,10 +694,10 @@ class ScannerViewModelTest { assertEquals(DeviceType.BLE, viewModel.activeTransport.value) viewModel.onSelected(tcpEntry) - assertEquals(DeviceType.TCP, viewModel.activeTransport.value) + assertEquals(DeviceType.BLE, viewModel.activeTransport.value) viewModel.onSelected(usbEntry) - assertEquals(DeviceType.USB, viewModel.activeTransport.value) + assertEquals(DeviceType.BLE, viewModel.activeTransport.value) } // ── persistNetworkAutoScanIntent invariant ─────────────────────────────────────────────── diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index ccbfe6b5d0..656618393c 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -36,6 +36,7 @@ fun MapScreen( modifier: Modifier = Modifier, viewModel: SharedMapViewModel, waypointId: Int? = null, + navigateToNodeCompass: (Int) -> Unit = navigateToNodeDetails, ) { val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() @@ -59,6 +60,7 @@ fun MapScreen( modifier = Modifier.fillMaxSize().padding(paddingValues), navigateToNodeDetails = navigateToNodeDetails, waypointId = waypointId, + navigateToNodeCompass = navigateToNodeCompass, ) } } diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt deleted file mode 100644 index 7fc3ba4c6f..0000000000 --- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 - -import android.database.sqlite.SQLiteDatabase -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import java.io.File -import kotlin.test.assertEquals - -@RunWith(RobolectricTestRunner::class) -class MBTilesProviderTest { - - @get:Rule val tempFolder = TemporaryFolder() - - @Test - fun `getTile translates y coordinate correctly to TMS`() { - val dbFile = tempFolder.newFile("test.mbtiles") - setupMockDatabase(dbFile) - - val provider = MBTilesProvider(dbFile) - - // Google Maps zoom 1, x=0, y=0 - // TMS y = (1 << 1) - 1 - 0 = 1 - provider.getTile(0, 0, 1) - - // We verify the query was correct by checking the database if we could, - // but here we just ensure it doesn't crash and returns the expected No Tile if missing. - // To truly test, we'd need to insert data. - - val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE) - db.execSQL("INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (1, 0, 1, x'1234')") - db.close() - - val tile = provider.getTile(0, 0, 1) - assertEquals(256, tile?.width) - assertEquals(256, tile?.height) - // Robolectric SQLite might return different blob handling, but let's see. - } - - private fun setupMockDatabase(file: File) { - val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY) - db.execSQL("CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)") - db.close() - } -} diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt deleted file mode 100644 index d665bff2dd..0000000000 --- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * 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 - -import android.app.Application -import androidx.lifecycle.SavedStateHandle -import com.google.android.gms.maps.model.UrlTileProvider -import dev.mokkery.MockMode -import dev.mokkery.every -import dev.mokkery.mock -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.feature.map.model.CustomTileProviderConfig -import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs -import org.meshtastic.feature.map.repository.CustomTileProviderRepository -import org.robolectric.RobolectricTestRunner -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class MapViewModelTest { - - private val application = mock(MockMode.autofill) - private val mapPrefs = mock(MockMode.autofill) - private val googleMapsPrefs = mock(MockMode.autofill) - private val nodeRepository = FakeNodeRepository() - private val packetRepository = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - private val radioController = FakeRadioController() - private val customTileProviderRepository = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) - private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null)) - - private val testDispatcher = StandardTestDispatcher() - - private lateinit var viewModel: MapViewModel - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - every { mapPrefs.mapStyle } returns MutableStateFlow(0) - every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false) - every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(true) - every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(true) - every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L) - every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L) - - every { googleMapsPrefs.cameraTargetLat } returns MutableStateFlow(0.0) - every { googleMapsPrefs.cameraTargetLng } returns MutableStateFlow(0.0) - every { googleMapsPrefs.cameraZoom } returns MutableStateFlow(0f) - every { googleMapsPrefs.cameraTilt } returns MutableStateFlow(0f) - every { googleMapsPrefs.cameraBearing } returns MutableStateFlow(0f) - every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null) - every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null) - every { googleMapsPrefs.hiddenLayerUrls } returns MutableStateFlow(emptySet()) - - every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList()) - every { radioConfigRepository.deviceProfileFlow } returns flowOf(mock(MockMode.autofill)) - every { uiPrefs.theme } returns MutableStateFlow(1) - every { packetRepository.getWaypoints() } returns flowOf(emptyList()) - - viewModel = - MapViewModel( - application, - mapPrefs, - googleMapsPrefs, - nodeRepository, - packetRepository, - radioConfigRepository, - radioController, - customTileProviderRepository, - uiPrefs, - savedStateHandle, - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `getTileProvider returns UrlTileProvider for remote config`() = runTest { - val config = - CustomTileProviderConfig( - name = "OpenStreetMap", - urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - ) - - val provider = viewModel.getTileProvider(config) - assertTrue(provider is UrlTileProvider) - } - - @Test - fun `addNetworkMapLayer detects GeoJSON based on extension`() = runTest(testDispatcher) { - viewModel.addNetworkMapLayer("Test Layer", "https://example.com/data.geojson") - advanceUntilIdle() - - val layer = viewModel.mapLayers.value.find { it.name == "Test Layer" } - assertEquals(LayerType.GEOJSON, layer?.layerType) - } - - @Test - fun `addNetworkMapLayer defaults to KML for other extensions`() = runTest(testDispatcher) { - viewModel.addNetworkMapLayer("Test KML", "https://example.com/map.kml") - advanceUntilIdle() - - val layer = viewModel.mapLayers.value.find { it.name == "Test KML" } - assertEquals(LayerType.KML, layer?.layerType) - } - - @Test - fun `setWaypointId updates value correctly including null`() = runTest(testDispatcher) { - // Set to a valid ID - viewModel.setWaypointId(123) - assertEquals(123, viewModel.selectedWaypointId.value) - - // Set to null should clear the selection - viewModel.setWaypointId(null) - assertEquals(null, viewModel.selectedWaypointId.value) - } -} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 3234de5871..56c35ab022 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.foundation.layout.size import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults @@ -23,6 +24,19 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * Minimum touch target from the Meshtastic design standards (`.skills/design-standards`). + * + * Set explicitly rather than inherited: Material's [FilledIconButton] container default is smaller, which left the map + * controls under the standard. Sized to the 44dp minimum rather than the roomier 48dp because the map toolbar does not + * scroll — at 48dp a fully-populated toolbar overflows a 360dp-wide screen and buttons get clipped. + */ +private val MapButtonTouchTarget = 44.dp + +/** Icon glyph size. Larger than Material's 24dp default so the symbol stays readable at arm's length outdoors. */ +private val MapButtonIconSize = 26.dp /** * A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance @@ -36,11 +50,12 @@ fun MapButton( modifier: Modifier = Modifier, iconTint: Color? = null, ) { - FilledIconButton(onClick = onClick, modifier = modifier) { + FilledIconButton(onClick = onClick, modifier = modifier.size(MapButtonTouchTarget)) { Icon( imageVector = icon, contentDescription = contentDescription, tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor, + modifier = Modifier.size(MapButtonIconSize), ) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index b71585034c..212596102d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.compass_find_favorite import org.meshtastic.core.resources.map_filter import org.meshtastic.core.resources.orient_north import org.meshtastic.core.resources.refresh @@ -43,6 +44,7 @@ import org.meshtastic.core.ui.icon.MapCompass import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MyLocation import org.meshtastic.core.ui.icon.Refresh +import org.meshtastic.core.ui.icon.Route import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusBlue import org.meshtastic.core.ui.theme.StatusColors.StatusRed @@ -80,6 +82,7 @@ fun MapControlsOverlay( showRefresh: Boolean = false, isRefreshing: Boolean = false, onRefresh: () -> Unit = {}, + onFindFavoriteClick: (() -> Unit)? = null, ) { HorizontalFloatingToolbar( expanded = true, @@ -89,6 +92,16 @@ fun MapControlsOverlay( // Compass CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) + // Shortcut to the bearing-and-distance compass for the favourite node. Shown only when there is a favourite + // with a known position, so it never appears as a button that does nothing. + onFindFavoriteClick?.let { onClick -> + MapButton( + icon = MeshtasticIcons.Route, + contentDescription = stringResource(Res.string.compass_find_favorite), + onClick = onClick, + ) + } + // Filter button + dropdown Box { MapButton( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index 00df4cac3b..fb14e78b7f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -29,6 +29,8 @@ fun EntryProviderScope.mapGraph(backStack: NavBackStack) { { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails args.waypointId, + // navigateToNodeCompass: lands on the compass itself, not the detail screen it lives in. + { id -> backStack.add(NodesRoute.NodeDetail(id, openCompass = true)) }, ) } } diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml index fe8ce9410f..f57027b041 100644 --- a/feature/node/detekt-baseline.xml +++ b/feature/node/detekt-baseline.xml @@ -85,6 +85,6 @@ PreviewPublic:NodeListItemPreviews.kt:@PreviewLightDark @Composable fun NodeItemCompletePreview TooGenericExceptionCaught:MetricsViewModel.kt:MetricsViewModel$e: Exception TooGenericExceptionCaught:NodeManagementActions.kt:NodeManagementActions$ex: Exception - ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, ) + ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, openCompass = openCompass, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 8d6c4ec67e..907bd34640 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -65,6 +65,7 @@ fun NodeDetailScreen( onNavigate: (Route) -> Unit = {}, onNavigateUp: () -> Unit = {}, compassViewModel: CompassViewModel? = null, + openCompass: Boolean = false, ) { LaunchedEffect(nodeId) { viewModel.start(nodeId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -77,6 +78,7 @@ fun NodeDetailScreen( onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, + openCompass = openCompass, ) } @@ -90,6 +92,7 @@ private fun NodeDetailScaffold( onNavigate: (Route) -> Unit, onNavigateUp: () -> Unit, compassViewModel: CompassViewModel? = null, + openCompass: Boolean = false, ) { var activeOverlay by remember { mutableStateOf(null) } val actualCompassViewModel = compassViewModel @@ -97,6 +100,17 @@ private fun NodeDetailScaffold( actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) } val node = uiState.node + + // Opens the compass for a caller that asked for it (see NodesRoute.NodeDetail.openCompass). Waits for the node to + // load, and keys on it so a dismissed overlay is not immediately reopened by a later recomposition. + var compassAutoOpened by remember(node?.num) { mutableStateOf(false) } + LaunchedEffect(openCompass, node, compassAutoOpened) { + if (openCompass && node != null && !compassAutoOpened) { + compassAutoOpened = true + actualCompassViewModel?.start(node, uiState.metricsState.displayUnits) + activeOverlay = NodeDetailOverlay.Compass + } + } val listState = rememberLazyListState() Scaffold( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 6d5a618794..a4bee53eac 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -102,6 +102,7 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) nodeId = destNum, viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, + openCompass = args.openCompass, navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) }, onNavigate = { route -> backStack.add(route) }, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },