From 0a6fcc830a9f71c51a0ad31235c8897b5a8baaee Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:36:56 -0600 Subject: [PATCH] feat(maps): Google maps improvements for network and offline tilesources (#4664) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../com/geeksville/mesh/MapsInitializer.kt | 25 -- .../com/geeksville/mesh/MapsInitializer.kt | 25 -- app/src/main/AndroidManifest.xml | 4 +- .../geeksville/mesh/MeshUtilApplication.kt | 1 - .../data/model/CustomTileProviderConfig.kt | 6 +- .../core/prefs/map/GoogleMapsPrefs.kt | 5 +- .../composeResources/values/strings.xml | 25 +- feature/intro/build.gradle.kts | 16 +- .../feature/intro/AppIntroductionScreen.kt | 170 +++------- .../meshtastic/feature/intro/IntroNavGraph.kt | 129 ++++++++ .../feature/intro/IntroViewModel.kt | 40 +++ .../feature/intro/IntroViewModelTest.kt | 73 +++++ feature/map/build.gradle.kts | 6 + .../meshtastic/feature/map/MBTilesProvider.kt | 65 ++++ .../org/meshtastic/feature/map/MapView.kt | 118 ++++--- .../meshtastic/feature/map/MapViewModel.kt | 309 ++++++++++++------ .../map/component/CustomMapLayersSheet.kt | 91 +++++- .../CustomTileProviderManagerSheet.kt | 78 ++++- .../map/component/MapControlsOverlay.kt | 23 ++ .../map/component/NodeClusterMarkers.kt | 3 +- .../feature/map/model/NodeClusterItem.kt | 15 +- .../feature/map/MBTilesProviderTest.kt | 63 ++++ .../feature/map/MapViewModelTest.kt | 149 +++++++++ 23 files changed, 1086 insertions(+), 353 deletions(-) delete mode 100644 app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt delete mode 100644 app/src/google/java/com/geeksville/mesh/MapsInitializer.kt create mode 100644 feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt create mode 100644 feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt create mode 100644 feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt create mode 100644 feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt create mode 100644 feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt create mode 100644 feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt 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) + } +}