feat(maps): Google maps improvements for network and offline tilesources (#4664)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-27 08:36:56 -06:00 committed by GitHub
parent 22c239016b
commit 0a6fcc830a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1086 additions and 353 deletions

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import android.content.Context
@Suppress("UNUSED_PARAMETER")
fun initializeMaps(context: Context) {
// No-op for F-Droid
}

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import android.content.Context
import com.google.android.gms.maps.MapsInitializer
fun initializeMaps(context: Context) {
MapsInitializer.initialize(context)
}

View file

@ -44,8 +44,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Permissions required for providing location (from phone GPS) to mesh -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:remove="android:maxSdkVersion" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:remove="android:maxSdkVersion" />
<!-- This permission is required for analytics - and soon the MQTT gateway -->
<uses-permission android:name="android.permission.INTERNET" />

View file

@ -69,7 +69,6 @@ open class MeshUtilApplication :
override fun onCreate() {
super.onCreate()
ContextServices.app = this
initializeMaps(this)
// Schedule periodic MeshLog cleanup
scheduleMeshLogCleanup()

View file

@ -24,4 +24,8 @@ data class CustomTileProviderConfig(
val id: String = Uuid.random().toString(),
val name: String,
val urlTemplate: String,
)
val localUri: String? = null,
) {
val isLocal: Boolean
get() = localUri != null
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.map
import android.content.SharedPreferences
@ -37,6 +36,7 @@ interface GoogleMapsPrefs {
var cameraZoom: Float
var cameraTilt: Float
var cameraBearing: Float
var networkMapLayers: Set<String>
}
@Singleton
@ -50,4 +50,5 @@ class GoogleMapsPrefsImpl @Inject constructor(@GoogleMapsSharedPreferences prefs
override var cameraZoom: Float by FloatPrefDelegate(prefs, "camera_zoom", 7f)
override var cameraTilt: Float by FloatPrefDelegate(prefs, "camera_tilt", 0f)
override var cameraBearing: Float by FloatPrefDelegate(prefs, "camera_bearing", 0f)
override var networkMapLayers: Set<String> by StringSetPrefDelegate(prefs, "network_map_layers", emptySet())
}

View file

@ -970,9 +970,9 @@
<string name="map_type_terrain">Terrain</string>
<string name="map_type_hybrid">Hybrid</string>
<string name="manage_map_layers">Manage Map Layers</string>
<string name="map_layer_formats">Custom layers support .kml, .kmz, or GeoJSON files.</string>
<string name="map_layer_formats">Map layers support .kml, .kmz, or GeoJSON formats.</string>
<string name="map_layers_title">Map Layers</string>
<string name="no_map_layers_loaded">No custom layers loaded.</string>
<string name="no_map_layers_loaded">No map layers loaded.</string>
<string name="add_layer_button">Add Layer</string>
<string name="hide_layer">Hide Layer</string>
<string name="show_layer">Show Layer</string>
@ -981,16 +981,16 @@
<string name="nodes_at_this_location">Nodes at this location</string>
<string name="selected_map_type">Selected Map Type</string>
<string name="manage_custom_tile_sources">Manage Custom Tile Sources</string>
<string name="add_custom_tile_source">Add Custom Tile Source</string>
<string name="no_custom_tile_sources_found">No Custom Tile Sources</string>
<string name="edit_custom_tile_source">Edit Custom Tile Source</string>
<string name="delete_custom_tile_source">Delete Custom Tile Source</string>
<string name="add_custom_tile_source">Add Network Tile Source</string>
<string name="no_custom_tile_sources_found">No custom tile sources found.</string>
<string name="edit_custom_tile_source">Edit Network Tile Source</string>
<string name="delete_custom_tile_source">Delete Network Tile Source</string>
<string name="name_cannot_be_empty">Name cannot be empty.</string>
<string name="provider_name_exists">Provider name exists.</string>
<string name="url_cannot_be_empty">URL cannot be empty.</string>
<string name="url_must_contain_placeholders">URL must contain placeholders.</string>
<string name="url_template">URL Template</string>
<string name="url_template_hint" translatable="false">https://a.tile.openstreetmap.org/{z}/{x}/{y}.png</string>
<string name="url_template_hint" translatable="false">https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png</string>
<string name="track_point">track point</string>
<string name="app_settings">App</string>
<string name="app_version">Version</string>
@ -1212,4 +1212,15 @@
<string name="meshtastic_stats">Meshtastic Stats</string>
<string name="refresh">Refresh</string>
<string name="updated">Updated</string>
<!-- Network Map Layers -->
<string name="add_network_layer">Add Network Layer</string>
<string name="network_layer_url_hint" translatable="false">https://example.com/map.kml or .geojson</string>
<string name="refresh_layer">Refresh Layer</string>
<string name="local_mbtiles_file">Local MBTiles File</string>
<string name="add_local_mbtiles_file">Add Local MBTiles File</string>
<string name="error_invalid_custom_provider">Invalid name, URL template, or local URI for custom tile provider.</string>
<string name="error_provider_exists">A custom tile provider with this name already exists.</string>
<string name="error_copy_mbtiles_failed">Failed to copy MBTiles file to internal storage.</string>
</resources>

View file

@ -24,17 +24,27 @@ plugins {
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
configure<LibraryExtension> { namespace = "org.meshtastic.feature.intro" }
configure<LibraryExtension> {
namespace = "org.meshtastic.feature.intro"
testOptions { unitTests { isIncludeAndroidResources = true } }
}
dependencies {
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.nordic.common.permissions.ble)
implementation(libs.nordic.common.permissions.notification)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.test.core)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
}

View file

@ -16,150 +16,60 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.provider.Settings
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import kotlinx.serialization.Serializable
import no.nordicsemi.android.common.permissions.ble.RequireBluetooth
import no.nordicsemi.android.common.permissions.ble.RequireLocation
import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.permission_denied
import org.meshtastic.core.resources.permission_granted
import org.meshtastic.core.ui.util.showToast
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
/**
* Composable function for the main application introduction screen. This screen guides the user through initial setup
* steps like granting permissions.
* Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states.
*
* @param onDone Callback invoked when the introduction flow is completed.
* @param viewModel ViewModel for tracking the introduction flow state.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AppIntroductionScreen(onDone: () -> Unit) {
val context = LocalContext.current
fun AppIntroductionScreen(onDone: () -> Unit, @Suppress("unused") viewModel: IntroViewModel = hiltViewModel()) {
val notificationPermissionState: PermissionState? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
} else {
null
}
val locationPermissions =
listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
val locationPermissionState = rememberMultiplePermissionsState(permissions = locationPermissions)
val bluetoothPermissions =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
// On older versions, location permission is used for scanning.
emptyList()
}
val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions)
val backStack = rememberNavBackStack(Welcome)
NavDisplay(
NavDisplay<NavKey>(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
entryProvider {
entry<Welcome> { WelcomeScreen(onGetStarted = { backStack.add(Notifications) }) }
entry<Notifications> {
var isConfiguring by remember { mutableStateOf(false) }
if (isConfiguring) {
RequestNotificationPermission { canShowNotifications ->
LaunchedEffect(canShowNotifications) {
if (canShowNotifications == true) {
context.showToast(Res.string.permission_granted)
} else if (canShowNotifications == false) {
context.showToast(Res.string.permission_denied)
}
}
NotificationsScreen(
showNextButton = canShowNotifications == true,
onSkip = { backStack.add(Bluetooth) },
onConfigure = {
if (canShowNotifications == true) {
backStack.add(CriticalAlerts)
}
},
)
}
} else {
NotificationsScreen(
showNextButton = false,
onSkip = { backStack.add(Bluetooth) },
onConfigure = { isConfiguring = true },
)
}
}
entry<CriticalAlerts> {
CriticalAlertsScreen(
onSkip = { backStack.add(Bluetooth) },
onConfigure = {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
}
context.startActivity(intent)
backStack.add(Bluetooth)
},
)
}
entry<Bluetooth> {
var isConfiguring by remember { mutableStateOf(false) }
if (isConfiguring) {
RequireBluetooth {
LaunchedEffect(Unit) {
context.showToast(Res.string.permission_granted)
backStack.add(Location)
}
}
} else {
BluetoothScreen(
showNextButton = false,
onSkip = { backStack.add(Location) },
onConfigure = { isConfiguring = true },
)
}
}
entry<Location> {
var isConfiguring by remember { mutableStateOf(false) }
if (isConfiguring) {
RequireLocation { isLocationRequiredAndDisabled ->
LaunchedEffect(isLocationRequiredAndDisabled) {
if (!isLocationRequiredAndDisabled) {
context.showToast(Res.string.permission_granted)
} else {
context.showToast(Res.string.permission_denied)
}
}
LocationScreen(
showNextButton = !isLocationRequiredAndDisabled,
onSkip = onDone,
onConfigure = {
if (!isLocationRequiredAndDisabled) {
onDone()
}
},
)
}
} else {
LocationScreen(showNextButton = false, onSkip = onDone, onConfigure = { isConfiguring = true })
}
}
},
introNavGraph(
backStack = backStack,
viewModel = viewModel,
notificationPermissionState = notificationPermissionState,
bluetoothPermissionState = bluetoothPermissionState,
locationPermissionState = locationPermissionState,
onDone = onDone,
),
)
}
@Serializable private data object Welcome : NavKey
@Serializable private data object Notifications : NavKey
@Serializable private data object CriticalAlerts : NavKey
@Serializable private data object Bluetooth : NavKey
@Serializable private data object Location : NavKey

View file

@ -0,0 +1,129 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import kotlinx.serialization.Serializable
@Serializable data object Welcome : NavKey
@Serializable data object Bluetooth : NavKey
@Serializable data object Location : NavKey
@Serializable data object Notifications : NavKey
@Serializable data object CriticalAlerts : NavKey
/**
* Provides the navigation graph for the application introduction flow. The flow follows the hierarchy of necessity:
* Core Connection -> Shared Location -> Notifications.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
@Suppress("LongMethod")
internal fun introNavGraph(
backStack: NavBackStack<NavKey>,
viewModel: IntroViewModel,
notificationPermissionState: PermissionState?,
bluetoothPermissionState: MultiplePermissionsState,
locationPermissionState: MultiplePermissionsState,
onDone: () -> Unit,
) = entryProvider {
val context = LocalContext.current
fun navigateToNext(current: NavKey, permissionsGranted: Boolean = true) {
val next = viewModel.getNextKey(current, permissionsGranted)
if (next != null) {
backStack.add(next)
} else {
onDone()
}
}
entry<Welcome> { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
entry<Bluetooth> {
val isGranted = bluetoothPermissionState.allPermissionsGranted
BluetoothScreen(
showNextButton = isGranted,
onSkip = { navigateToNext(Bluetooth) },
onConfigure = {
if (isGranted) {
navigateToNext(Bluetooth)
} else {
bluetoothPermissionState.launchMultiplePermissionRequest()
}
},
)
}
entry<Location> {
val isGranted = locationPermissionState.allPermissionsGranted
LocationScreen(
showNextButton = isGranted,
onSkip = { navigateToNext(Location) },
onConfigure = {
if (isGranted) {
navigateToNext(Location)
} else {
locationPermissionState.launchMultiplePermissionRequest()
}
},
)
}
entry<Notifications> {
val isGranted = notificationPermissionState?.status?.isGranted ?: true
NotificationsScreen(
showNextButton = isGranted,
onSkip = onDone,
onConfigure = {
if (notificationPermissionState != null && !isGranted) {
notificationPermissionState.launchPermissionRequest()
} else {
navigateToNext(Notifications, permissionsGranted = isGranted)
}
},
)
}
entry<CriticalAlerts> {
CriticalAlertsScreen(
onSkip = onDone,
onConfigure = {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
}
context.startActivity(intent)
onDone()
},
)
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import androidx.lifecycle.ViewModel
import androidx.navigation3.runtime.NavKey
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/** ViewModel for the app introduction flow. */
@HiltViewModel
class IntroViewModel @Inject constructor() : ViewModel() {
/**
* Determines the next navigation key based on the current key and the state of permissions. The flow hierarchy is:
* Core Connection -> Shared Location -> Notifications -> Done.
*/
fun getNextKey(currentKey: NavKey, allPermissionsGranted: Boolean): NavKey? = when (currentKey) {
is Welcome -> Bluetooth
is Bluetooth -> Location
is Location -> Notifications
is Notifications -> if (allPermissionsGranted) CriticalAlerts else null
is CriticalAlerts -> null
else -> null
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class IntroViewModelTest {
@Test
fun `viewModel can be initialized`() {
val viewModel = IntroViewModel()
assertNotNull(viewModel)
}
@Test
fun `getNextKey returns Bluetooth after Welcome`() {
val viewModel = IntroViewModel()
val next = viewModel.getNextKey(Welcome, false)
assertEquals(Bluetooth, next)
}
@Test
fun `getNextKey returns Location after Bluetooth`() {
val viewModel = IntroViewModel()
val next = viewModel.getNextKey(Bluetooth, false)
assertEquals(Location, next)
}
@Test
fun `getNextKey returns Notifications after Location`() {
val viewModel = IntroViewModel()
val next = viewModel.getNextKey(Location, false)
assertEquals(Notifications, next)
}
@Test
fun `getNextKey returns CriticalAlerts after Notifications if granted`() {
val viewModel = IntroViewModel()
val next = viewModel.getNextKey(Notifications, true)
assertEquals(CriticalAlerts, next)
}
@Test
fun `getNextKey returns null after Notifications if not granted`() {
val viewModel = IntroViewModel()
val next = viewModel.getNextKey(Notifications, false)
assertNull(next)
}
@Test
fun `getNextKey returns null after CriticalAlerts`() {
val viewModel = IntroViewModel()
val next = viewModel.getNextKey(CriticalAlerts, false)
assertNull(next)
}
}

View file

@ -62,4 +62,10 @@ dependencies {
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.test.core)
}

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import android.database.sqlite.SQLiteDatabase
import com.google.android.gms.maps.model.Tile
import com.google.android.gms.maps.model.TileProvider
import java.io.File
class MBTilesProvider(private val file: File) :
TileProvider,
AutoCloseable {
private var database: SQLiteDatabase? = null
init {
openDatabase()
}
private fun openDatabase() {
if (database == null && file.exists()) {
database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
}
}
override fun getTile(x: Int, y: Int, zoom: Int): Tile? {
val db = database ?: return null
var tile: Tile? = null
// Convert Google Maps y coordinate to standard TMS y coordinate
val tmsY = (1 shl zoom) - 1 - y
val cursor =
db.rawQuery(
"SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
arrayOf(zoom.toString(), x.toString(), tmsY.toString()),
)
if (cursor.moveToFirst()) {
val tileData = cursor.getBlob(0)
tile = Tile(256, 256, tileData)
}
cursor.close()
return tile ?: TileProvider.NO_TILE
}
override fun close() {
database?.close()
database = null
}
}

View file

@ -91,8 +91,11 @@ import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.widgets.ScaleBar
import com.google.maps.android.data.geojson.GeoJsonLayer
import com.google.maps.android.data.kml.KmlLayer
import kotlinx.coroutines.launch
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
@ -265,12 +268,7 @@ fun MapView(
}
}
DisposableEffect(Unit) {
onDispose {
fusedLocationClient.removeLocationUpdates(locationCallback)
mapViewModel.clearLoadedLayerData()
}
}
DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
@ -307,6 +305,7 @@ fun MapView(
}
}
val myNodeNum = mapViewModel.myNodeNum
val nodeClusterItems =
displayNodes.map { node ->
val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
@ -315,6 +314,7 @@ fun MapView(
nodePosition = latLng,
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
nodeSnippet = "${node.user.long_name}",
myNodeNum = myNodeNum,
)
}
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
@ -467,7 +467,11 @@ fun MapView(
) {
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
val config =
mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
it.urlTemplate == url || it.localUri == url
}
mapViewModel.getTileProvider(config)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
}
@ -508,8 +512,15 @@ fun MapView(
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
val color = Color(focusedNode.colors.second).copy(alpha = alpha)
val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
val activeNodeZIndex = if (isHighPriority) 5f else 4f
if (index == sortedPositions.lastIndex) {
MarkerComposable(state = markerState, zIndex = 4f) {
MarkerComposable(
state = markerState,
zIndex = activeNodeZIndex,
alpha = if (isHighPriority) 1.0f else 0.9f,
) {
NodeChip(node = focusedNode)
}
} else {
@ -590,34 +601,7 @@ fun MapView(
selectedWaypointId = selectedWaypointId,
)
MapEffect(mapLayers) { map ->
mapLayers.forEach { layerItem ->
coroutineScope.launch {
mapViewModel.loadMapLayerIfNeeded(map, layerItem)
when (layerItem.layerType) {
LayerType.KML -> {
layerItem.kmlLayerData?.let { kmlLayer ->
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
kmlLayer.addLayerToMap()
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
kmlLayer.removeLayerFromMap()
}
}
}
LayerType.GEOJSON -> {
layerItem.geoJsonLayerData?.let { geoJsonLayer ->
if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) {
geoJsonLayer.addLayerToMap()
} else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) {
geoJsonLayer.removeLayerFromMap()
}
}
}
}
}
}
}
mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
}
ScaleBar(
@ -651,6 +635,10 @@ fun MapView(
)
}
val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
val showRefresh = visibleNetworkLayers.isNotEmpty()
val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
mapFilterMenuExpanded = mapFilterMenuExpanded,
@ -696,12 +684,22 @@ fun MapView(
}
},
followPhoneBearing = followPhoneBearing,
showRefresh = showRefresh,
isRefreshing = isRefreshingLayers,
onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
)
}
}
if (showLayersBottomSheet) {
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked)
CustomMapLayersSheet(
mapLayers = mapLayers,
onToggleVisibility = onToggleVisibility,
onRemoveLayer = onRemoveLayer,
onAddLayerClicked = onAddLayerClicked,
onRefreshLayer = { mapViewModel.refreshMapLayer(it) },
onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
)
}
}
showClusterItemsDialog?.let {
@ -721,6 +719,52 @@ fun MapView(
}
}
@Composable
private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
val context = LocalContext.current
var currentLayer by remember { mutableStateOf<com.google.maps.android.data.Layer?>(null) }
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
val layer =
try {
when (layerItem.layerType) {
LayerType.KML -> KmlLayer(map, inputStream, context)
LayerType.GEOJSON ->
GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
}
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
null
}
layer?.let {
if (layerItem.isVisible) {
it.addLayerToMap()
}
currentLayer = it
}
}
DisposableEffect(layerItem.id) {
onDispose {
currentLayer?.removeLayerFromMap()
currentLayer = null
}
}
// Handle visibility changes without reloading the whole layer if possible,
// though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have.
LaunchedEffect(layerItem.isVisible) {
val layer = currentLayer ?: return@LaunchedEffect
if (layerItem.isVisible) {
if (!layer.isLayerOnMap) layer.addLayerToMap()
} else {
if (layer.isLayerOnMap) layer.removeLayerFromMap()
}
}
}
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
String(Character.toChars(unicodeCodePoint))
} catch (e: IllegalArgumentException) {

View file

@ -23,15 +23,12 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
import com.google.maps.android.data.geojson.GeoJsonLayer
import com.google.maps.android.data.kml.KmlLayer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
@ -46,7 +43,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.json.JSONObject
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.data.repository.CustomTileProviderRepository
import org.meshtastic.core.data.repository.NodeRepository
@ -136,10 +132,14 @@ constructor(
.mapNotNull { it.config?.display?.units }
.stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
fun addCustomTileProvider(name: String, urlTemplate: String) {
fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
viewModelScope.launch {
if (name.isBlank() || urlTemplate.isBlank() || !isValidTileUrlTemplate(urlTemplate)) {
_errorFlow.emit("Invalid name or URL template for custom tile provider.")
if (
name.isBlank() ||
(urlTemplate.isBlank() && localUri == null) ||
(localUri == null && !isValidTileUrlTemplate(urlTemplate))
) {
_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) }) {
@ -147,7 +147,27 @@ constructor(
return@launch
}
val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate)
var finalLocalUri = localUri
if (localUri != null) {
try {
val uri = Uri.parse(localUri)
val extension = "mbtiles"
val finalFileName = "mbtiles_${Uuid.random()}.$extension"
val copiedUri = copyFileToInternalStorage(uri, finalFileName)
if (copiedUri != null) {
finalLocalUri = copiedUri.toString()
} else {
_errorFlow.emit("Failed to copy MBTiles file to internal storage.")
return@launch
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error processing local URI" }
_errorFlow.emit("Error processing local URI for MBTiles.")
return@launch
}
}
val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri)
customTileProviderRepository.addCustomTileProvider(newConfig)
}
}
@ -156,10 +176,10 @@ constructor(
viewModelScope.launch {
if (
configToUpdate.name.isBlank() ||
configToUpdate.urlTemplate.isBlank() ||
!isValidTileUrlTemplate(configToUpdate.urlTemplate)
(configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) ||
(configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate))
) {
_errorFlow.emit("Invalid name or URL template for updating custom tile provider.")
_errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.")
return@launch
}
val existingConfigs = customTileProviderConfigs.value
@ -195,29 +215,43 @@ constructor(
val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
customTileProviderRepository.deleteCustomTileProvider(configId)
if (configToRemove != null && _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
googleMapsPrefs.selectedCustomTileUrl = null
if (configToRemove != null) {
if (
_selectedCustomTileProviderUrl.value == configToRemove.urlTemplate ||
_selectedCustomTileProviderUrl.value == configToRemove.localUri
) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
googleMapsPrefs.selectedCustomTileUrl = null
}
if (configToRemove.localUri != null) {
val uri = Uri.parse(configToRemove.localUri)
deleteFileToInternalStorage(uri)
}
}
}
}
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
if (config != null) {
if (!isValidTileUrlTemplate(config.urlTemplate)) {
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
googleMapsPrefs.selectedCustomTileUrl = null
return
}
_selectedCustomTileProviderUrl.value = config.urlTemplate
_selectedGoogleMapType.value = MapType.NORMAL // Reset to a default or keep last? For now, reset.
googleMapsPrefs.selectedCustomTileUrl = config.urlTemplate
// Use localUri if present, otherwise urlTemplate
val selectedUrl = config.localUri ?: config.urlTemplate
_selectedCustomTileProviderUrl.value = selectedUrl
_selectedGoogleMapType.value = MapType.NONE
googleMapsPrefs.selectedCustomTileUrl = selectedUrl
googleMapsPrefs.selectedGoogleMapType = null
} else {
_selectedCustomTileProviderUrl.value = null
_selectedGoogleMapType.value = MapType.NORMAL
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.selectedGoogleMapType = MapType.NORMAL.name
}
}
@ -228,27 +262,68 @@ constructor(
googleMapsPrefs.selectedCustomTileUrl = null
}
fun createUrlTileProvider(urlString: String): TileProvider? {
if (!isValidTileUrlTemplate(urlString)) {
Logger.withTag("MapViewModel")
.e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
private var currentTileProvider: TileProvider? = null
fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? {
if (config == null) {
(currentTileProvider as? MBTilesProvider)?.close()
currentTileProvider = null
return null
}
return object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
val formattedUrl =
urlString
.replace("{z}", zoom.toString(), ignoreCase = true)
.replace("{x}", x.toString(), ignoreCase = true)
.replace("{y}", y.toString(), ignoreCase = true)
return try {
URL(formattedUrl)
} catch (e: MalformedURLException) {
Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
val selectedUrl = config.localUri ?: config.urlTemplate
if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) {
return currentTileProvider
}
// Close previous if it was a local provider
(currentTileProvider as? MBTilesProvider)?.close()
val newProvider =
if (config.isLocal) {
val uri = Uri.parse(config.localUri)
val file =
try {
uri.toFile()
} catch (e: Exception) {
File(uri.path ?: "")
}
if (file.exists()) {
MBTilesProvider(file)
} else {
Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}")
null
}
} else {
val urlString = config.urlTemplate
if (!isValidTileUrlTemplate(urlString)) {
Logger.withTag("MapViewModel")
.e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
null
} else {
object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
val subdomains = listOf("a", "b", "c")
val subdomain = subdomains[(x + y) % subdomains.size]
val formattedUrl =
urlString
.replace("{s}", subdomain, ignoreCase = true)
.replace("{z}", zoom.toString(), ignoreCase = true)
.replace("{x}", x.toString(), ignoreCase = true)
.replace("{y}", y.toString(), ignoreCase = true)
return try {
URL(formattedUrl)
} catch (e: MalformedURLException) {
Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
null
}
}
}
}
}
}
currentTileProvider = newProvider
return newProvider
}
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
@ -296,7 +371,8 @@ constructor(
isValidTileUrlTemplate(savedCustomUrl)
) {
_selectedCustomTileProviderUrl.value = savedCustomUrl
_selectedGoogleMapType.value = MapType.NORMAL // Default, as custom is active
_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.selectedCustomTileUrl = null
@ -351,9 +427,34 @@ constructor(
null
}
}
_mapLayers.value = loadedItems
if (loadedItems.isNotEmpty()) {
Logger.withTag("MapViewModel").i("Loaded ${loadedItems.size} persisted map layers.")
val networkItems =
googleMapsPrefs.networkMapLayers.mapNotNull { networkString ->
try {
val parts = networkString.split("|:|")
if (parts.size == 3) {
val id = parts[0]
val name = parts[1]
val uri = Uri.parse(parts[2])
MapLayerItem(
id = id,
name = name,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = LayerType.KML,
isNetwork = true,
)
} else {
null
}
} catch (e: Exception) {
null
}
}
_mapLayers.value = loadedItems + networkItems
if (_mapLayers.value.isNotEmpty()) {
Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.")
}
}
} else {
@ -407,6 +508,37 @@ constructor(
}
}
fun addNetworkMapLayer(name: String, url: String) {
viewModelScope.launch {
if (name.isBlank() || url.isBlank()) {
_errorFlow.emit("Invalid name or URL for network layer.")
return@launch
}
try {
val uri = Uri.parse(url)
if (uri.scheme != "http" && uri.scheme != "https") {
_errorFlow.emit("URL must be http or https.")
return@launch
}
val path = uri.path?.lowercase() ?: ""
val layerType =
when {
path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON
else -> LayerType.KML // Default to KML
}
val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
_mapLayers.value = _mapLayers.value + newItem
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
googleMapsPrefs.networkMapLayers = googleMapsPrefs.networkMapLayers + networkLayerString
} catch (e: Exception) {
_errorFlow.emit("Invalid URL.")
}
}
}
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
@ -450,19 +582,32 @@ constructor(
fun removeMapLayer(layerId: String) {
viewModelScope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
when (layerToRemove?.layerType) {
LayerType.KML -> layerToRemove.kmlLayerData?.removeLayerFromMap()
LayerType.GEOJSON -> layerToRemove.geoJsonLayerData?.removeLayerFromMap()
null -> {}
}
layerToRemove?.uri?.let { uri ->
deleteFileToInternalStorage(uri)
if (layerToRemove.isNetwork) {
googleMapsPrefs.networkMapLayers =
googleMapsPrefs.networkMapLayers.filterNot { it.startsWith("$layerId|:|") }.toSet()
} else {
deleteFileToInternalStorage(uri)
}
googleMapsPrefs.hiddenLayerUrls -= uri.toString()
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
}
fun refreshMapLayer(layerId: String) {
viewModelScope.launch {
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } }
// By resetting the layer data in the UI (implied by just refreshing),
// we trigger a reload in the Composable.
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } }
}
}
fun refreshAllVisibleNetworkLayers() {
_mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
try {
@ -477,70 +622,26 @@ constructor(
}
@Suppress("Recycle")
private suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(Dispatchers.IO) {
try {
application.contentResolver.openInputStream(uriToLoad)
} catch (_: Exception) {
Logger.d { "MapViewModel: Error opening InputStream from URI: $uriToLoad" }
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
val url = java.net.URL(uriToLoad.toString())
java.io.BufferedInputStream(url.openStream())
} else {
application.contentResolver.openInputStream(uriToLoad)
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" }
null
}
}
}
suspend fun loadMapLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem) {
if (layerItem.kmlLayerData != null || layerItem.geoJsonLayerData != null) return
try {
when (layerItem.layerType) {
LayerType.KML -> loadKmlLayerIfNeeded(layerItem, map)
LayerType.GEOJSON -> loadGeoJsonLayerIfNeeded(layerItem, map)
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error loading map layer for ${layerItem.uri}" }
}
}
private suspend fun loadKmlLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) {
val kmlLayer =
getInputStreamFromUri(layerItem)?.use {
KmlLayer(map, it, application.applicationContext).apply {
if (!layerItem.isVisible) removeLayerFromMap()
}
}
_mapLayers.update { currentLayers ->
currentLayers.map {
if (it.id == layerItem.id) {
it.copy(kmlLayerData = kmlLayer)
} else {
it
}
}
}
}
private suspend fun loadGeoJsonLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) {
val geoJsonLayer =
getInputStreamFromUri(layerItem)?.use { inputStream ->
val jsonObject = JSONObject(inputStream.bufferedReader().use { it.readText() })
GeoJsonLayer(map, jsonObject).apply { if (!layerItem.isVisible) removeLayerFromMap() }
}
_mapLayers.update { currentLayers ->
currentLayers.map {
if (it.id == layerItem.id) {
it.copy(geoJsonLayerData = geoJsonLayer)
} else {
it
}
}
}
}
fun clearLoadedLayerData() {
_mapLayers.update { currentLayers ->
currentLayers.map { it.copy(kmlLayerData = null, geoJsonLayerData = null) }
}
override fun onCleared() {
super.onCleared()
(currentTileProvider as? MBTilesProvider)?.close()
}
}
@ -553,8 +654,8 @@ data class MapLayerItem(
val id: String = Uuid.random().toString(),
val name: String,
val uri: Uri? = null,
var isVisible: Boolean = true,
var kmlLayerData: KmlLayer? = null,
var geoJsonLayerData: GeoJsonLayer? = null,
val isVisible: Boolean = true,
val layerType: LayerType,
val isNetwork: Boolean = false,
val isRefreshing: Boolean = false,
)

View file

@ -16,36 +16,55 @@
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
import org.meshtastic.core.resources.add_network_layer
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.hide_layer
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_layer_formats
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.network_layer_url_hint
import org.meshtastic.core.resources.no_map_layers_loaded
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.remove_layer
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.map.MapLayerItem
@Suppress("LongMethod")
@ -56,7 +75,10 @@ fun CustomMapLayersSheet(
onToggleVisibility: (String) -> Unit,
onRemoveLayer: (String) -> Unit,
onAddLayerClicked: () -> Unit,
onRefreshLayer: (String) -> Unit,
onAddNetworkLayer: (String, String) -> Unit,
) {
var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
@ -87,7 +109,22 @@ fun CustomMapLayersSheet(
ListItem(
headlineContent = { Text(layer.name) },
trailingContent = {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
if (layer.isNetwork) {
if (layer.isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(4.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(onClick = { onRefreshLayer(layer.id) }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(Res.string.refresh),
)
}
}
}
IconButton(onClick = { onToggleVisibility(layer.id) }) {
Icon(
imageVector =
@ -119,9 +156,57 @@ fun CustomMapLayersSheet(
}
}
item {
Button(modifier = Modifier.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) {
Text(stringResource(Res.string.add_layer))
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) {
Text(stringResource(Res.string.add_layer))
}
Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) {
Text(stringResource(Res.string.add_network_layer))
}
}
}
}
if (showAddNetworkLayerDialog) {
AddNetworkLayerDialog(
onDismiss = { showAddNetworkLayerDialog = false },
onConfirm = { name, url ->
onAddNetworkLayer(name, url)
showAddNetworkLayerDialog = false
},
)
}
}
@Composable
fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.add_network_layer),
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(Res.string.url)) },
placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
onConfirm = { onConfirm(name, url) },
confirmTextRes = Res.string.save,
dismissTextRes = Res.string.cancel,
)
}

View file

@ -16,6 +16,9 @@
*/
package org.meshtastic.feature.map.component
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -51,9 +54,11 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_custom_tile_source
import org.meshtastic.core.resources.add_local_mbtiles_file
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.delete_custom_tile_source
import org.meshtastic.core.resources.edit_custom_tile_source
import org.meshtastic.core.resources.local_mbtiles_file
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.name_cannot_be_empty
@ -76,6 +81,21 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
var showEditDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
val mbtilesPickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
result.data?.data?.let { uri ->
val fileName = uri.getFileName(context)
val baseName = fileName.substringBeforeLast('.')
mapViewModel.addCustomTileProvider(
name = baseName,
urlTemplate = "", // Empty for local
localUri = uri.toString(),
)
}
}
}
LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } }
if (showEditDialog) {
@ -116,7 +136,16 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
items(customTileProviders, key = { it.id }) { config ->
ListItem(
headlineContent = { Text(config.name) },
supportingContent = { Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) },
supportingContent = {
if (config.isLocal) {
Text(
stringResource(Res.string.local_mbtiles_file),
style = MaterialTheme.typography.bodySmall,
)
} else {
Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall)
}
},
trailingContent = {
Row {
IconButton(
@ -144,14 +173,30 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
}
item {
Button(
onClick = {
editingConfig = null
showEditDialog = true
},
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Text(stringResource(Res.string.add_custom_tile_source))
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
editingConfig = null
showEditDialog = true
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.add_custom_tile_source))
}
Button(
onClick = {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
mbtilesPickerLauncher.launch(intent)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.add_local_mbtiles_file))
}
}
}
}
@ -262,3 +307,18 @@ private fun validateUrl(url: String, emptyUrlError: String, mustContainPlacehold
} else {
null
}
private fun android.net.Uri.getFileName(context: android.content.Context): String {
var name = this.lastPathSegment ?: "mbtiles_file"
if (this.scheme == "content") {
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (displayNameIndex != -1) {
name = cursor.getString(displayNameIndex)
}
}
}
}
return name
}

View file

@ -17,26 +17,32 @@
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Navigation
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.MyLocation
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.rounded.LocationDisabled
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.manage_map_layers
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.map.MapViewModel
@ -61,6 +67,9 @@ fun MapControlsOverlay(
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean,
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
HorizontalFloatingToolbar(
modifier = modifier,
@ -115,6 +124,20 @@ fun MapControlsOverlay(
onClick = onManageLayersClicked,
)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
}
} else {
MapButton(
icon = Icons.Filled.Refresh,
contentDescription = stringResource(Res.string.refresh),
onClick = onRefresh,
)
}
}
// Location tracking button
MapButton(
icon =

View file

@ -89,7 +89,8 @@ fun NodeClusterMarkers(
}
}
}
ClusteringMarkerProperties(zIndex = 1f)
// Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others)
ClusteringMarkerProperties(zIndex = clusterItem.getZIndex())
},
)
}

View file

@ -20,15 +20,24 @@ import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
import org.meshtastic.core.database.model.Node
data class NodeClusterItem(val node: Node, val nodePosition: LatLng, val nodeTitle: String, val nodeSnippet: String) :
ClusterItem {
data class NodeClusterItem(
val node: Node,
val nodePosition: LatLng,
val nodeTitle: String,
val nodeSnippet: String,
val myNodeNum: Int? = null,
) : ClusterItem {
override fun getPosition(): LatLng = nodePosition
override fun getTitle(): String = nodeTitle
override fun getSnippet(): String = nodeSnippet
override fun getZIndex(): Float? = null
override fun getZIndex(): Float = when {
node.num == myNodeNum -> 5.0f // My node is always highest
node.isFavorite -> 5.0f // Favorites are equally high priority
else -> 4.0f
}
fun getPrecisionMeters(): Double? {
val precisionMap =

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import android.database.sqlite.SQLiteDatabase
import org.junit.Assert.assertEquals
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
@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()
}
}

View file

@ -0,0 +1,149 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import android.app.Application
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import com.google.android.gms.maps.model.UrlTileProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
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.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
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.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class MapViewModelTest {
private val application = mockk<Application>(relaxed = true)
private val mapPrefs = mockk<MapPrefs>(relaxed = true)
private val googleMapsPrefs = mockk<GoogleMapsPrefs>(relaxed = true)
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 customTileProviderRepository = mockk<CustomTileProviderRepository>(relaxed = true)
private val uiPreferencesDataSource = mockk<UiPreferencesDataSource>(relaxed = true)
private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null))
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: MapViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
every { radioConfigRepository.deviceProfileFlow } returns flowOf(mockk(relaxed = true))
every { uiPreferencesDataSource.theme } returns MutableStateFlow(1)
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
every { nodeRepository.myId } returns MutableStateFlow(null)
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)
viewModel =
MapViewModel(
application,
mapPrefs,
googleMapsPrefs,
nodeRepository,
packetRepository,
radioConfigRepository,
serviceRepository,
customTileProviderRepository,
uiPreferencesDataSource,
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) {
mockkStatic(Uri::class)
val mockUri = mockk<Uri>()
every { Uri.parse("https://example.com/data.geojson") } returns mockUri
every { mockUri.scheme } returns "https"
every { mockUri.path } returns "/data.geojson"
every { mockUri.toString() } returns "https://example.com/data.geojson"
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) {
mockkStatic(Uri::class)
val mockUri = mockk<Uri>()
every { Uri.parse("https://example.com/map.kml") } returns mockUri
every { mockUri.scheme } returns "https"
every { mockUri.path } returns "/map.kml"
every { mockUri.toString() } returns "https://example.com/map.kml"
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)
}
}