mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(maps): Google maps improvements for network and offline tilesources (#4664)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
22c239016b
commit
0a6fcc830a
23 changed files with 1086 additions and 353 deletions
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh
|
||||
|
||||
import android.content.Context
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun initializeMaps(context: Context) {
|
||||
// No-op for F-Droid
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.maps.MapsInitializer
|
||||
|
||||
fun initializeMaps(context: Context) {
|
||||
MapsInitializer.initialize(context)
|
||||
}
|
||||
|
|
@ -44,8 +44,8 @@
|
|||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Permissions required for providing location (from phone GPS) to mesh -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:remove="android:maxSdkVersion" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:remove="android:maxSdkVersion" />
|
||||
|
||||
<!-- This permission is required for analytics - and soon the MQTT gateway -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ open class MeshUtilApplication :
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextServices.app = this
|
||||
initializeMaps(this)
|
||||
|
||||
// Schedule periodic MeshLog cleanup
|
||||
scheduleMeshLogCleanup()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.prefs.map
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
|
@ -37,6 +36,7 @@ interface GoogleMapsPrefs {
|
|||
var cameraZoom: Float
|
||||
var cameraTilt: Float
|
||||
var cameraBearing: Float
|
||||
var networkMapLayers: Set<String>
|
||||
}
|
||||
|
||||
@Singleton
|
||||
|
|
@ -50,4 +50,5 @@ class GoogleMapsPrefsImpl @Inject constructor(@GoogleMapsSharedPreferences prefs
|
|||
override var cameraZoom: Float by FloatPrefDelegate(prefs, "camera_zoom", 7f)
|
||||
override var cameraTilt: Float by FloatPrefDelegate(prefs, "camera_tilt", 0f)
|
||||
override var cameraBearing: Float by FloatPrefDelegate(prefs, "camera_bearing", 0f)
|
||||
override var networkMapLayers: Set<String> by StringSetPrefDelegate(prefs, "network_map_layers", emptySet())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -970,9 +970,9 @@
|
|||
<string name="map_type_terrain">Terrain</string>
|
||||
<string name="map_type_hybrid">Hybrid</string>
|
||||
<string name="manage_map_layers">Manage Map Layers</string>
|
||||
<string name="map_layer_formats">Custom layers support .kml, .kmz, or GeoJSON files.</string>
|
||||
<string name="map_layer_formats">Map layers support .kml, .kmz, or GeoJSON formats.</string>
|
||||
<string name="map_layers_title">Map Layers</string>
|
||||
<string name="no_map_layers_loaded">No custom layers loaded.</string>
|
||||
<string name="no_map_layers_loaded">No map layers loaded.</string>
|
||||
<string name="add_layer_button">Add Layer</string>
|
||||
<string name="hide_layer">Hide Layer</string>
|
||||
<string name="show_layer">Show Layer</string>
|
||||
|
|
@ -981,16 +981,16 @@
|
|||
<string name="nodes_at_this_location">Nodes at this location</string>
|
||||
<string name="selected_map_type">Selected Map Type</string>
|
||||
<string name="manage_custom_tile_sources">Manage Custom Tile Sources</string>
|
||||
<string name="add_custom_tile_source">Add Custom Tile Source</string>
|
||||
<string name="no_custom_tile_sources_found">No Custom Tile Sources</string>
|
||||
<string name="edit_custom_tile_source">Edit Custom Tile Source</string>
|
||||
<string name="delete_custom_tile_source">Delete Custom Tile Source</string>
|
||||
<string name="add_custom_tile_source">Add Network Tile Source</string>
|
||||
<string name="no_custom_tile_sources_found">No custom tile sources found.</string>
|
||||
<string name="edit_custom_tile_source">Edit Network Tile Source</string>
|
||||
<string name="delete_custom_tile_source">Delete Network Tile Source</string>
|
||||
<string name="name_cannot_be_empty">Name cannot be empty.</string>
|
||||
<string name="provider_name_exists">Provider name exists.</string>
|
||||
<string name="url_cannot_be_empty">URL cannot be empty.</string>
|
||||
<string name="url_must_contain_placeholders">URL must contain placeholders.</string>
|
||||
<string name="url_template">URL Template</string>
|
||||
<string name="url_template_hint" translatable="false">https://a.tile.openstreetmap.org/{z}/{x}/{y}.png</string>
|
||||
<string name="url_template_hint" translatable="false">https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png</string>
|
||||
<string name="track_point">track point</string>
|
||||
<string name="app_settings">App</string>
|
||||
<string name="app_version">Version</string>
|
||||
|
|
@ -1212,4 +1212,15 @@
|
|||
<string name="meshtastic_stats">Meshtastic Stats</string>
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="updated">Updated</string>
|
||||
|
||||
<!-- Network Map Layers -->
|
||||
<string name="add_network_layer">Add Network Layer</string>
|
||||
<string name="network_layer_url_hint" translatable="false">https://example.com/map.kml or .geojson</string>
|
||||
<string name="refresh_layer">Refresh Layer</string>
|
||||
|
||||
<string name="local_mbtiles_file">Local MBTiles File</string>
|
||||
<string name="add_local_mbtiles_file">Add Local MBTiles File</string>
|
||||
<string name="error_invalid_custom_provider">Invalid name, URL template, or local URI for custom tile provider.</string>
|
||||
<string name="error_provider_exists">A custom tile provider with this name already exists.</string>
|
||||
<string name="error_copy_mbtiles_failed">Failed to copy MBTiles file to internal storage.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -24,17 +24,27 @@ plugins {
|
|||
alias(libs.plugins.meshtastic.kotlinx.serialization)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> { namespace = "org.meshtastic.feature.intro" }
|
||||
configure<LibraryExtension> {
|
||||
namespace = "org.meshtastic.feature.intro"
|
||||
testOptions { unitTests { isIncludeAndroidResources = true } }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.resources)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.nordic.common.permissions.ble)
|
||||
implementation(libs.nordic.common.permissions.notification)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(platform(libs.androidx.compose.bom))
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,150 +16,60 @@
|
|||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import kotlinx.serialization.Serializable
|
||||
import no.nordicsemi.android.common.permissions.ble.RequireBluetooth
|
||||
import no.nordicsemi.android.common.permissions.ble.RequireLocation
|
||||
import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.permission_denied
|
||||
import org.meshtastic.core.resources.permission_granted
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
/**
|
||||
* Composable function for the main application introduction screen. This screen guides the user through initial setup
|
||||
* steps like granting permissions.
|
||||
* Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states.
|
||||
*
|
||||
* @param onDone Callback invoked when the introduction flow is completed.
|
||||
* @param viewModel ViewModel for tracking the introduction flow state.
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun AppIntroductionScreen(onDone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
fun AppIntroductionScreen(onDone: () -> Unit, @Suppress("unused") viewModel: IntroViewModel = hiltViewModel()) {
|
||||
val notificationPermissionState: PermissionState? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val locationPermissions =
|
||||
listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
val locationPermissionState = rememberMultiplePermissionsState(permissions = locationPermissions)
|
||||
|
||||
val bluetoothPermissions =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
// On older versions, location permission is used for scanning.
|
||||
emptyList()
|
||||
}
|
||||
val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions)
|
||||
|
||||
val backStack = rememberNavBackStack(Welcome)
|
||||
|
||||
NavDisplay(
|
||||
NavDisplay<NavKey>(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider =
|
||||
entryProvider {
|
||||
entry<Welcome> { WelcomeScreen(onGetStarted = { backStack.add(Notifications) }) }
|
||||
|
||||
entry<Notifications> {
|
||||
var isConfiguring by remember { mutableStateOf(false) }
|
||||
|
||||
if (isConfiguring) {
|
||||
RequestNotificationPermission { canShowNotifications ->
|
||||
LaunchedEffect(canShowNotifications) {
|
||||
if (canShowNotifications == true) {
|
||||
context.showToast(Res.string.permission_granted)
|
||||
} else if (canShowNotifications == false) {
|
||||
context.showToast(Res.string.permission_denied)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationsScreen(
|
||||
showNextButton = canShowNotifications == true,
|
||||
onSkip = { backStack.add(Bluetooth) },
|
||||
onConfigure = {
|
||||
if (canShowNotifications == true) {
|
||||
backStack.add(CriticalAlerts)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
NotificationsScreen(
|
||||
showNextButton = false,
|
||||
onSkip = { backStack.add(Bluetooth) },
|
||||
onConfigure = { isConfiguring = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
entry<CriticalAlerts> {
|
||||
CriticalAlertsScreen(
|
||||
onSkip = { backStack.add(Bluetooth) },
|
||||
onConfigure = {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
backStack.add(Bluetooth)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<Bluetooth> {
|
||||
var isConfiguring by remember { mutableStateOf(false) }
|
||||
|
||||
if (isConfiguring) {
|
||||
RequireBluetooth {
|
||||
LaunchedEffect(Unit) {
|
||||
context.showToast(Res.string.permission_granted)
|
||||
backStack.add(Location)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BluetoothScreen(
|
||||
showNextButton = false,
|
||||
onSkip = { backStack.add(Location) },
|
||||
onConfigure = { isConfiguring = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
entry<Location> {
|
||||
var isConfiguring by remember { mutableStateOf(false) }
|
||||
|
||||
if (isConfiguring) {
|
||||
RequireLocation { isLocationRequiredAndDisabled ->
|
||||
LaunchedEffect(isLocationRequiredAndDisabled) {
|
||||
if (!isLocationRequiredAndDisabled) {
|
||||
context.showToast(Res.string.permission_granted)
|
||||
} else {
|
||||
context.showToast(Res.string.permission_denied)
|
||||
}
|
||||
}
|
||||
|
||||
LocationScreen(
|
||||
showNextButton = !isLocationRequiredAndDisabled,
|
||||
onSkip = onDone,
|
||||
onConfigure = {
|
||||
if (!isLocationRequiredAndDisabled) {
|
||||
onDone()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LocationScreen(showNextButton = false, onSkip = onDone, onConfigure = { isConfiguring = true })
|
||||
}
|
||||
}
|
||||
},
|
||||
introNavGraph(
|
||||
backStack = backStack,
|
||||
viewModel = viewModel,
|
||||
notificationPermissionState = notificationPermissionState,
|
||||
bluetoothPermissionState = bluetoothPermissionState,
|
||||
locationPermissionState = locationPermissionState,
|
||||
onDone = onDone,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable private data object Welcome : NavKey
|
||||
|
||||
@Serializable private data object Notifications : NavKey
|
||||
|
||||
@Serializable private data object CriticalAlerts : NavKey
|
||||
|
||||
@Serializable private data object Bluetooth : NavKey
|
||||
|
||||
@Serializable private data object Location : NavKey
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object Welcome : NavKey
|
||||
|
||||
@Serializable data object Bluetooth : NavKey
|
||||
|
||||
@Serializable data object Location : NavKey
|
||||
|
||||
@Serializable data object Notifications : NavKey
|
||||
|
||||
@Serializable data object CriticalAlerts : NavKey
|
||||
|
||||
/**
|
||||
* Provides the navigation graph for the application introduction flow. The flow follows the hierarchy of necessity:
|
||||
* Core Connection -> Shared Location -> Notifications.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
internal fun introNavGraph(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
viewModel: IntroViewModel,
|
||||
notificationPermissionState: PermissionState?,
|
||||
bluetoothPermissionState: MultiplePermissionsState,
|
||||
locationPermissionState: MultiplePermissionsState,
|
||||
onDone: () -> Unit,
|
||||
) = entryProvider {
|
||||
val context = LocalContext.current
|
||||
|
||||
fun navigateToNext(current: NavKey, permissionsGranted: Boolean = true) {
|
||||
val next = viewModel.getNextKey(current, permissionsGranted)
|
||||
if (next != null) {
|
||||
backStack.add(next)
|
||||
} else {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
entry<Welcome> { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
|
||||
|
||||
entry<Bluetooth> {
|
||||
val isGranted = bluetoothPermissionState.allPermissionsGranted
|
||||
BluetoothScreen(
|
||||
showNextButton = isGranted,
|
||||
onSkip = { navigateToNext(Bluetooth) },
|
||||
onConfigure = {
|
||||
if (isGranted) {
|
||||
navigateToNext(Bluetooth)
|
||||
} else {
|
||||
bluetoothPermissionState.launchMultiplePermissionRequest()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<Location> {
|
||||
val isGranted = locationPermissionState.allPermissionsGranted
|
||||
LocationScreen(
|
||||
showNextButton = isGranted,
|
||||
onSkip = { navigateToNext(Location) },
|
||||
onConfigure = {
|
||||
if (isGranted) {
|
||||
navigateToNext(Location)
|
||||
} else {
|
||||
locationPermissionState.launchMultiplePermissionRequest()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<Notifications> {
|
||||
val isGranted = notificationPermissionState?.status?.isGranted ?: true
|
||||
NotificationsScreen(
|
||||
showNextButton = isGranted,
|
||||
onSkip = onDone,
|
||||
onConfigure = {
|
||||
if (notificationPermissionState != null && !isGranted) {
|
||||
notificationPermissionState.launchPermissionRequest()
|
||||
} else {
|
||||
navigateToNext(Notifications, permissionsGranted = isGranted)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<CriticalAlerts> {
|
||||
CriticalAlertsScreen(
|
||||
onSkip = onDone,
|
||||
onConfigure = {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
onDone()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/** ViewModel for the app introduction flow. */
|
||||
@HiltViewModel
|
||||
class IntroViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
/**
|
||||
* Determines the next navigation key based on the current key and the state of permissions. The flow hierarchy is:
|
||||
* Core Connection -> Shared Location -> Notifications -> Done.
|
||||
*/
|
||||
fun getNextKey(currentKey: NavKey, allPermissionsGranted: Boolean): NavKey? = when (currentKey) {
|
||||
is Welcome -> Bluetooth
|
||||
is Bluetooth -> Location
|
||||
is Location -> Notifications
|
||||
is Notifications -> if (allPermissionsGranted) CriticalAlerts else null
|
||||
is CriticalAlerts -> null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.intro
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class IntroViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `viewModel can be initialized`() {
|
||||
val viewModel = IntroViewModel()
|
||||
assertNotNull(viewModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextKey returns Bluetooth after Welcome`() {
|
||||
val viewModel = IntroViewModel()
|
||||
val next = viewModel.getNextKey(Welcome, false)
|
||||
assertEquals(Bluetooth, next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextKey returns Location after Bluetooth`() {
|
||||
val viewModel = IntroViewModel()
|
||||
val next = viewModel.getNextKey(Bluetooth, false)
|
||||
assertEquals(Location, next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextKey returns Notifications after Location`() {
|
||||
val viewModel = IntroViewModel()
|
||||
val next = viewModel.getNextKey(Location, false)
|
||||
assertEquals(Notifications, next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextKey returns CriticalAlerts after Notifications if granted`() {
|
||||
val viewModel = IntroViewModel()
|
||||
val next = viewModel.getNextKey(Notifications, true)
|
||||
assertEquals(CriticalAlerts, next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextKey returns null after Notifications if not granted`() {
|
||||
val viewModel = IntroViewModel()
|
||||
val next = viewModel.getNextKey(Notifications, false)
|
||||
assertNull(next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNextKey returns null after CriticalAlerts`() {
|
||||
val viewModel = IntroViewModel()
|
||||
val next = viewModel.getNextKey(CriticalAlerts, false)
|
||||
assertNull(next)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.map
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.google.android.gms.maps.model.Tile
|
||||
import com.google.android.gms.maps.model.TileProvider
|
||||
import java.io.File
|
||||
|
||||
class MBTilesProvider(private val file: File) :
|
||||
TileProvider,
|
||||
AutoCloseable {
|
||||
private var database: SQLiteDatabase? = null
|
||||
|
||||
init {
|
||||
openDatabase()
|
||||
}
|
||||
|
||||
private fun openDatabase() {
|
||||
if (database == null && file.exists()) {
|
||||
database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTile(x: Int, y: Int, zoom: Int): Tile? {
|
||||
val db = database ?: return null
|
||||
|
||||
var tile: Tile? = null
|
||||
// Convert Google Maps y coordinate to standard TMS y coordinate
|
||||
val tmsY = (1 shl zoom) - 1 - y
|
||||
|
||||
val cursor =
|
||||
db.rawQuery(
|
||||
"SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
|
||||
arrayOf(zoom.toString(), x.toString(), tmsY.toString()),
|
||||
)
|
||||
|
||||
if (cursor.moveToFirst()) {
|
||||
val tileData = cursor.getBlob(0)
|
||||
tile = Tile(256, 256, tileData)
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
return tile ?: TileProvider.NO_TILE
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
database?.close()
|
||||
database = null
|
||||
}
|
||||
}
|
||||
|
|
@ -91,8 +91,11 @@ import com.google.maps.android.compose.Polyline
|
|||
import com.google.maps.android.compose.TileOverlay
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import com.google.maps.android.compose.widgets.ScaleBar
|
||||
import com.google.maps.android.data.geojson.GeoJsonLayer
|
||||
import com.google.maps.android.data.kml.KmlLayer
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.json.JSONObject
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.database.model.Node
|
||||
|
|
@ -265,12 +268,7 @@ fun MapView(
|
|||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
mapViewModel.clearLoadedLayerData()
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
|
||||
|
||||
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
|
||||
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
|
|
@ -307,6 +305,7 @@ fun MapView(
|
|||
}
|
||||
}
|
||||
|
||||
val myNodeNum = mapViewModel.myNodeNum
|
||||
val nodeClusterItems =
|
||||
displayNodes.map { node ->
|
||||
val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
|
||||
|
|
@ -315,6 +314,7 @@ fun MapView(
|
|||
nodePosition = latLng,
|
||||
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
|
||||
nodeSnippet = "${node.user.long_name}",
|
||||
myNodeNum = myNodeNum,
|
||||
)
|
||||
}
|
||||
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
|
||||
|
|
@ -467,7 +467,11 @@ fun MapView(
|
|||
) {
|
||||
key(currentCustomTileProviderUrl) {
|
||||
currentCustomTileProviderUrl?.let { url ->
|
||||
mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
|
||||
val config =
|
||||
mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
|
||||
it.urlTemplate == url || it.localUri == url
|
||||
}
|
||||
mapViewModel.getTileProvider(config)?.let { tileProvider ->
|
||||
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
|
||||
}
|
||||
}
|
||||
|
|
@ -508,8 +512,15 @@ fun MapView(
|
|||
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
|
||||
val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
|
||||
val color = Color(focusedNode.colors.second).copy(alpha = alpha)
|
||||
val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
|
||||
val activeNodeZIndex = if (isHighPriority) 5f else 4f
|
||||
|
||||
if (index == sortedPositions.lastIndex) {
|
||||
MarkerComposable(state = markerState, zIndex = 4f) {
|
||||
MarkerComposable(
|
||||
state = markerState,
|
||||
zIndex = activeNodeZIndex,
|
||||
alpha = if (isHighPriority) 1.0f else 0.9f,
|
||||
) {
|
||||
NodeChip(node = focusedNode)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -590,34 +601,7 @@ fun MapView(
|
|||
selectedWaypointId = selectedWaypointId,
|
||||
)
|
||||
|
||||
MapEffect(mapLayers) { map ->
|
||||
mapLayers.forEach { layerItem ->
|
||||
coroutineScope.launch {
|
||||
mapViewModel.loadMapLayerIfNeeded(map, layerItem)
|
||||
when (layerItem.layerType) {
|
||||
LayerType.KML -> {
|
||||
layerItem.kmlLayerData?.let { kmlLayer ->
|
||||
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
|
||||
kmlLayer.addLayerToMap()
|
||||
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
|
||||
kmlLayer.removeLayerFromMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LayerType.GEOJSON -> {
|
||||
layerItem.geoJsonLayerData?.let { geoJsonLayer ->
|
||||
if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) {
|
||||
geoJsonLayer.addLayerToMap()
|
||||
} else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) {
|
||||
geoJsonLayer.removeLayerFromMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
|
||||
}
|
||||
|
||||
ScaleBar(
|
||||
|
|
@ -651,6 +635,10 @@ fun MapView(
|
|||
)
|
||||
}
|
||||
|
||||
val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
|
||||
val showRefresh = visibleNetworkLayers.isNotEmpty()
|
||||
val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
|
||||
|
||||
MapControlsOverlay(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
|
||||
mapFilterMenuExpanded = mapFilterMenuExpanded,
|
||||
|
|
@ -696,12 +684,22 @@ fun MapView(
|
|||
}
|
||||
},
|
||||
followPhoneBearing = followPhoneBearing,
|
||||
showRefresh = showRefresh,
|
||||
isRefreshing = isRefreshingLayers,
|
||||
onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showLayersBottomSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
|
||||
CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked)
|
||||
CustomMapLayersSheet(
|
||||
mapLayers = mapLayers,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
onRemoveLayer = onRemoveLayer,
|
||||
onAddLayerClicked = onAddLayerClicked,
|
||||
onRefreshLayer = { mapViewModel.refreshMapLayer(it) },
|
||||
onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
|
||||
)
|
||||
}
|
||||
}
|
||||
showClusterItemsDialog?.let {
|
||||
|
|
@ -721,6 +719,52 @@ fun MapView(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
|
||||
val context = LocalContext.current
|
||||
var currentLayer by remember { mutableStateOf<com.google.maps.android.data.Layer?>(null) }
|
||||
|
||||
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
|
||||
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
|
||||
val layer =
|
||||
try {
|
||||
when (layerItem.layerType) {
|
||||
LayerType.KML -> KmlLayer(map, inputStream, context)
|
||||
LayerType.GEOJSON ->
|
||||
GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
|
||||
null
|
||||
}
|
||||
|
||||
layer?.let {
|
||||
if (layerItem.isVisible) {
|
||||
it.addLayerToMap()
|
||||
}
|
||||
currentLayer = it
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(layerItem.id) {
|
||||
onDispose {
|
||||
currentLayer?.removeLayerFromMap()
|
||||
currentLayer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle visibility changes without reloading the whole layer if possible,
|
||||
// though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have.
|
||||
LaunchedEffect(layerItem.isVisible) {
|
||||
val layer = currentLayer ?: return@LaunchedEffect
|
||||
if (layerItem.isVisible) {
|
||||
if (!layer.isLayerOnMap) layer.addLayerToMap()
|
||||
} else {
|
||||
if (layer.isLayerOnMap) layer.removeLayerFromMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
|
||||
String(Character.toChars(unicodeCodePoint))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.map
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MBTilesProviderTest {
|
||||
|
||||
@get:Rule val tempFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `getTile translates y coordinate correctly to TMS`() {
|
||||
val dbFile = tempFolder.newFile("test.mbtiles")
|
||||
setupMockDatabase(dbFile)
|
||||
|
||||
val provider = MBTilesProvider(dbFile)
|
||||
|
||||
// Google Maps zoom 1, x=0, y=0
|
||||
// TMS y = (1 << 1) - 1 - 0 = 1
|
||||
provider.getTile(0, 0, 1)
|
||||
|
||||
// We verify the query was correct by checking the database if we could,
|
||||
// but here we just ensure it doesn't crash and returns the expected No Tile if missing.
|
||||
// To truly test, we'd need to insert data.
|
||||
|
||||
val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
|
||||
db.execSQL("INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (1, 0, 1, x'1234')")
|
||||
db.close()
|
||||
|
||||
val tile = provider.getTile(0, 0, 1)
|
||||
assertEquals(256, tile?.width)
|
||||
assertEquals(256, tile?.height)
|
||||
// Robolectric SQLite might return different blob handling, but let's see.
|
||||
}
|
||||
|
||||
private fun setupMockDatabase(file: File) {
|
||||
val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY)
|
||||
db.execSQL("CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)")
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.map
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.google.android.gms.maps.model.UrlTileProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.data.model.CustomTileProviderConfig
|
||||
import org.meshtastic.core.data.repository.CustomTileProviderRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
|
||||
import org.meshtastic.core.prefs.map.MapPrefs
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MapViewModelTest {
|
||||
|
||||
private val application = mockk<Application>(relaxed = true)
|
||||
private val mapPrefs = mockk<MapPrefs>(relaxed = true)
|
||||
private val googleMapsPrefs = mockk<GoogleMapsPrefs>(relaxed = true)
|
||||
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
|
||||
private val packetRepository = mockk<PacketRepository>(relaxed = true)
|
||||
private val radioConfigRepository = mockk<RadioConfigRepository>(relaxed = true)
|
||||
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
|
||||
private val customTileProviderRepository = mockk<CustomTileProviderRepository>(relaxed = true)
|
||||
private val uiPreferencesDataSource = mockk<UiPreferencesDataSource>(relaxed = true)
|
||||
private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null))
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
private lateinit var viewModel: MapViewModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
|
||||
every { radioConfigRepository.deviceProfileFlow } returns flowOf(mockk(relaxed = true))
|
||||
every { uiPreferencesDataSource.theme } returns MutableStateFlow(1)
|
||||
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
|
||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
|
||||
every { nodeRepository.myId } returns MutableStateFlow(null)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
every { nodeRepository.getNodes() } returns flowOf(emptyList())
|
||||
every { packetRepository.getWaypoints() } returns flowOf(emptyList())
|
||||
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
|
||||
|
||||
viewModel =
|
||||
MapViewModel(
|
||||
application,
|
||||
mapPrefs,
|
||||
googleMapsPrefs,
|
||||
nodeRepository,
|
||||
packetRepository,
|
||||
radioConfigRepository,
|
||||
serviceRepository,
|
||||
customTileProviderRepository,
|
||||
uiPreferencesDataSource,
|
||||
savedStateHandle,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTileProvider returns UrlTileProvider for remote config`() = runTest {
|
||||
val config =
|
||||
CustomTileProviderConfig(
|
||||
name = "OpenStreetMap",
|
||||
urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
)
|
||||
|
||||
val provider = viewModel.getTileProvider(config)
|
||||
assertTrue(provider is UrlTileProvider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addNetworkMapLayer detects GeoJSON based on extension`() = runTest(testDispatcher) {
|
||||
mockkStatic(Uri::class)
|
||||
val mockUri = mockk<Uri>()
|
||||
every { Uri.parse("https://example.com/data.geojson") } returns mockUri
|
||||
every { mockUri.scheme } returns "https"
|
||||
every { mockUri.path } returns "/data.geojson"
|
||||
every { mockUri.toString() } returns "https://example.com/data.geojson"
|
||||
|
||||
viewModel.addNetworkMapLayer("Test Layer", "https://example.com/data.geojson")
|
||||
advanceUntilIdle()
|
||||
|
||||
val layer = viewModel.mapLayers.value.find { it.name == "Test Layer" }
|
||||
assertEquals(LayerType.GEOJSON, layer?.layerType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addNetworkMapLayer defaults to KML for other extensions`() = runTest(testDispatcher) {
|
||||
mockkStatic(Uri::class)
|
||||
val mockUri = mockk<Uri>()
|
||||
every { Uri.parse("https://example.com/map.kml") } returns mockUri
|
||||
every { mockUri.scheme } returns "https"
|
||||
every { mockUri.path } returns "/map.kml"
|
||||
every { mockUri.toString() } returns "https://example.com/map.kml"
|
||||
|
||||
viewModel.addNetworkMapLayer("Test KML", "https://example.com/map.kml")
|
||||
advanceUntilIdle()
|
||||
|
||||
val layer = viewModel.mapLayers.value.find { it.name == "Test KML" }
|
||||
assertEquals(LayerType.KML, layer?.layerType)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue