diff --git a/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt b/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt
deleted file mode 100644
index 8ae95519c..000000000
--- a/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt
+++ /dev/null
@@ -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 .
- */
-
-package com.geeksville.mesh
-
-import android.content.Context
-
-@Suppress("UNUSED_PARAMETER")
-fun initializeMaps(context: Context) {
- // No-op for F-Droid
-}
diff --git a/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt b/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt
deleted file mode 100644
index 5ae9b3963..000000000
--- a/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt
+++ /dev/null
@@ -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 .
- */
-
-package com.geeksville.mesh
-
-import android.content.Context
-import com.google.android.gms.maps.MapsInitializer
-
-fun initializeMaps(context: Context) {
- MapsInitializer.initialize(context)
-}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 90a786cb1..3c0e623aa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,8 +44,8 @@
-
-
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
index 8bb2e3dbb..9843c49f9 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
+++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
@@ -69,7 +69,6 @@ open class MeshUtilApplication :
override fun onCreate() {
super.onCreate()
ContextServices.app = this
- initializeMaps(this)
// Schedule periodic MeshLog cleanup
scheduleMeshLogCleanup()
diff --git a/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt b/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
index d35035985..434aa834e 100644
--- a/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
+++ b/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
@@ -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
+}
diff --git a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
index c749eba1c..73942c308 100644
--- a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
+++ b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
@@ -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 .
*/
-
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
}
@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 by StringSetPrefDelegate(prefs, "network_map_layers", emptySet())
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 8a46b2000..7376bd0a0 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -970,9 +970,9 @@
Terrain
Hybrid
Manage Map Layers
- Custom layers support .kml, .kmz, or GeoJSON files.
+ Map layers support .kml, .kmz, or GeoJSON formats.
Map Layers
- No custom layers loaded.
+ No map layers loaded.
Add Layer
Hide Layer
Show Layer
@@ -981,16 +981,16 @@
Nodes at this location
Selected Map Type
Manage Custom Tile Sources
- Add Custom Tile Source
- No Custom Tile Sources
- Edit Custom Tile Source
- Delete Custom Tile Source
+ Add Network Tile Source
+ No custom tile sources found.
+ Edit Network Tile Source
+ Delete Network Tile Source
Name cannot be empty.
Provider name exists.
URL cannot be empty.
URL must contain placeholders.
URL Template
- https://a.tile.openstreetmap.org/{z}/{x}/{y}.png
+ https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
track point
App
Version
@@ -1212,4 +1212,15 @@
Meshtastic Stats
Refresh
Updated
+
+
+ Add Network Layer
+ https://example.com/map.kml or .geojson
+ Refresh Layer
+
+ Local MBTiles File
+ Add Local MBTiles File
+ Invalid name, URL template, or local URI for custom tile provider.
+ A custom tile provider with this name already exists.
+ Failed to copy MBTiles file to internal storage.
diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts
index 21aa1b2db..026918527 100644
--- a/feature/intro/build.gradle.kts
+++ b/feature/intro/build.gradle.kts
@@ -24,17 +24,27 @@ plugins {
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
-configure { namespace = "org.meshtastic.feature.intro" }
+configure {
+ 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)
}
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
index 275dd84b4..5147bef41 100644
--- a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
@@ -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(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
- entryProvider {
- entry { WelcomeScreen(onGetStarted = { backStack.add(Notifications) }) }
-
- entry {
- 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 {
- 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 {
- 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 {
- 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
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
new file mode 100644
index 000000000..05c82bdd0
--- /dev/null
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
@@ -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 .
+ */
+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,
+ 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 { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
+
+ entry {
+ val isGranted = bluetoothPermissionState.allPermissionsGranted
+ BluetoothScreen(
+ showNextButton = isGranted,
+ onSkip = { navigateToNext(Bluetooth) },
+ onConfigure = {
+ if (isGranted) {
+ navigateToNext(Bluetooth)
+ } else {
+ bluetoothPermissionState.launchMultiplePermissionRequest()
+ }
+ },
+ )
+ }
+
+ entry {
+ val isGranted = locationPermissionState.allPermissionsGranted
+ LocationScreen(
+ showNextButton = isGranted,
+ onSkip = { navigateToNext(Location) },
+ onConfigure = {
+ if (isGranted) {
+ navigateToNext(Location)
+ } else {
+ locationPermissionState.launchMultiplePermissionRequest()
+ }
+ },
+ )
+ }
+
+ entry {
+ val isGranted = notificationPermissionState?.status?.isGranted ?: true
+ NotificationsScreen(
+ showNextButton = isGranted,
+ onSkip = onDone,
+ onConfigure = {
+ if (notificationPermissionState != null && !isGranted) {
+ notificationPermissionState.launchPermissionRequest()
+ } else {
+ navigateToNext(Notifications, permissionsGranted = isGranted)
+ }
+ },
+ )
+ }
+
+ entry {
+ 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()
+ },
+ )
+ }
+}
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
new file mode 100644
index 000000000..e76c007ed
--- /dev/null
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt b/feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
new file mode 100644
index 000000000..dfb129543
--- /dev/null
+++ b/feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts
index 539512077..c061bd993 100644
--- a/feature/map/build.gradle.kts
+++ b/feature/map/build.gradle.kts
@@ -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)
}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt
new file mode 100644
index 000000000..848779ccf
--- /dev/null
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
index 82bfe9f85..99725a8f8 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
@@ -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(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) {
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
index 68f1b60f7..03a4cc8c5 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
@@ -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,
)
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
index 3a9875ac0..51c655f32 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
@@ -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,
+ )
}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
index c34eb86c5..e65f5968d 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
@@ -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
+}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
index 7ad618683..042e8c58f 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
@@ -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 =
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
index 64f31d832..41c895c84 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
@@ -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())
},
)
}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
index 796e2fcc7..1930438fc 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
@@ -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 =
diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
new file mode 100644
index 000000000..3f2b5b586
--- /dev/null
+++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
@@ -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 .
+ */
+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()
+ }
+}
diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
new file mode 100644
index 000000000..571f3ac0d
--- /dev/null
+++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
@@ -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 .
+ */
+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(relaxed = true)
+ private val mapPrefs = mockk(relaxed = true)
+ private val googleMapsPrefs = mockk(relaxed = true)
+ private val nodeRepository = mockk(relaxed = true)
+ private val packetRepository = mockk(relaxed = true)
+ private val radioConfigRepository = mockk(relaxed = true)
+ private val serviceRepository = mockk(relaxed = true)
+ private val customTileProviderRepository = mockk(relaxed = true)
+ private val uiPreferencesDataSource = mockk(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()
+ 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()
+ 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)
+ }
+}