From 87e50e03ea60720a6aea56cf6e9b99e94a186524 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 13 Aug 2025 12:51:19 -0500
Subject: [PATCH] refactor: maps (#2097)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.github/workflows/release.yml | 1 +
.github/workflows/reusable-android-build.yml | 1 +
.gitignore | 2 +
README.md | 6 +
app/build.gradle.kts | 32 +-
.../geeksville/mesh/analytics/NopAnalytics.kt | 47 +-
.../mesh/android/GeeksvilleApplication.kt | 9 +-
.../mesh/model/map/CustomTileSource.kt | 209 ++++++
.../mesh/model/map/MarkerWithLabel.kt | 93 ++-
.../mesh/model/map/NOAAWmsTileSource.kt | 125 ++--
.../mesh/model/map/OnlineTileSourceAuth.kt | 56 +-
.../model/map/clustering/MarkerClusterer.java | 23 +-
.../map/clustering/RadiusMarkerClusterer.java | 20 +-
.../model/map/clustering/StaticCluster.java | 20 +-
.../com/geeksville/mesh/ui/map/MapView.kt | 77 +-
.../geeksville/mesh/ui/map/MapViewModel.kt | 39 +
.../mesh/ui/map/MapViewWithLifecycle.kt | 2 -
.../mesh/ui/map/components/CacheLayout.kt | 28 +-
.../mesh/ui/map/components/DownloadButton.kt | 30 +-
.../ui/map/components/EditWaypointDialog.kt | 235 +++---
.../mesh/ui/map/components/MapButton.kt | 27 +-
.../com/geeksville/mesh/ui/node/NodeMap.kt | 8 +-
.../java/com/geeksville/mesh/util/MapUtils.kt | 71 ++
.../geeksville/mesh/util/MapViewExtensions.kt | 128 ++--
.../geeksville/mesh/util/SqlTileWriterExt.kt | 69 +-
app/src/google/AndroidManifest.xml | 40 +
.../geeksville/mesh/MeshUtilApplication.kt | 4 +-
.../mesh/analytics/FirebaseAnalytics.kt | 19 +-
.../mesh/android/GeeksvilleApplication.kt | 6 +-
.../java/com/geeksville/mesh/di/MapModule.kt | 65 ++
.../mesh/model/map/CustomTileSource.kt | 27 +
.../map/CustomTileProviderRepository.kt | 33 +
...PreferencesCustomTileProviderRepository.kt | 104 +++
.../geeksville/mesh/ui/map/LocationHandler.kt | 140 ++++
.../com/geeksville/mesh/ui/map/MapView.kt | 701 ++++++++++++++++++
.../geeksville/mesh/ui/map/MapViewModel.kt | 383 ++++++++++
.../map/components/ClusterItemsListDialog.kt | 75 ++
.../ui/map/components/CustomMapLayersSheet.kt | 114 +++
.../CustomTileProviderManagerSheet.kt | 258 +++++++
.../ui/map/components/EditWaypointDialog.kt | 338 +++++++++
.../mesh/ui/map/components/MapButton.kt | 31 +
.../ui/map/components/MapControlsOverlay.kt | 93 +++
.../ui/map/components/MapFilterDropdown.kt | 89 +++
.../mesh/ui/map/components/MapTypeDropdown.kt | 104 +++
.../ui/map/components/NodeClusterMarkers.kt | 70 ++
.../mesh/ui/map/components/WaypointMarkers.kt | 70 ++
.../com/geeksville/mesh/ui/node/NodeMap.kt | 36 +
.../main/java/com/geeksville/mesh/NodeInfo.kt | 9 -
.../java/com/geeksville/mesh/model/Node.kt | 119 +--
.../java/com/geeksville/mesh/model/UIState.kt | 118 +--
.../mesh/model/map/CustomTileSource.kt | 212 ------
.../geeksville/mesh/navigation/MapRoutes.kt | 16 +-
.../geeksville/mesh/navigation/NavGraph.kt | 4 +-
.../geeksville/mesh/navigation/NodesRoutes.kt | 119 +--
.../mesh/ui/connections/Connections.kt | 9 +-
.../mesh/ui/map/BaseMapViewModel.kt | 106 +++
.../com/geeksville/mesh/ui/map/Constants.kt | 20 +
.../geeksville/mesh/ui/metrics/PositionLog.kt | 13 +-
.../com/geeksville/mesh/ui/node/NodeScreen.kt | 1 -
.../ui/node/components/LinkedCoordinates.kt | 111 +--
.../mesh/ui/node/components/NodeChip.kt | 2 +
.../mesh/ui/node/components/NodeItem.kt | 178 ++---
.../components/DisplayConfigItemList.kt | 77 +-
.../com/geeksville/mesh/ui/sharing/Channel.kt | 2 +-
.../com/geeksville/mesh/util/Extensions.kt | 15 +
.../com/geeksville/mesh/util/LocationUtils.kt | 321 +-------
.../mesh/util/SharedPreferenceExtensions.kt | 33 +
app/src/main/res/values/strings.xml | 26 +
.../java/com/geeksville/mesh/NodeInfoTest.kt | 19 +-
build.gradle.kts | 1 +
buildSrc/src/main/kotlin/Configs.kt | 2 +-
config/detekt/detekt-baseline.xml | 295 +-------
gradle/libs.versions.toml | 12 +-
.../main/java/com/geeksville/mesh/NodeInfo.kt | 9 -
network/build.gradle.kts | 10 +-
secrets.defaults.properties | 1 +
76 files changed, 4188 insertions(+), 1830 deletions(-)
create mode 100644 app/src/fdroid/java/com/geeksville/mesh/model/map/CustomTileSource.kt
rename app/src/{main => fdroid}/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt (66%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt (61%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt (61%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java (89%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java (91%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java (71%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/ui/map/MapView.kt (93%)
create mode 100644 app/src/fdroid/java/com/geeksville/mesh/ui/map/MapViewModel.kt
rename app/src/{main => fdroid}/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt (99%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt (81%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt (82%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt (61%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/ui/map/components/MapButton.kt (75%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/ui/node/NodeMap.kt (91%)
create mode 100644 app/src/fdroid/java/com/geeksville/mesh/util/MapUtils.kt
rename app/src/{main => fdroid}/java/com/geeksville/mesh/util/MapViewExtensions.kt (52%)
rename app/src/{main => fdroid}/java/com/geeksville/mesh/util/SqlTileWriterExt.kt (57%)
create mode 100644 app/src/google/AndroidManifest.xml
create mode 100644 app/src/google/java/com/geeksville/mesh/di/MapModule.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/model/map/CustomTileSource.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/repository/map/CustomTileProviderRepository.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/repository/map/SharedPreferencesCustomTileProviderRepository.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/LocationHandler.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/ClusterItemsListDialog.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/CustomMapLayersSheet.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/CustomTileProviderManagerSheet.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/MapButton.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/MapTypeDropdown.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/NodeClusterMarkers.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/map/components/WaypointMarkers.kt
create mode 100644 app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt
delete mode 100644 app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/util/SharedPreferenceExtensions.kt
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7cfef4fb9..aa0d525c9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -120,6 +120,7 @@ jobs:
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
+ echo "MAPS_API_KEY=$GOOGLE_MAPS_API_KEY" >> ./secrets.properties
env:
GSERVICES: ${{ secrets.GSERVICES }}
KEYSTORE: ${{ secrets.KEYSTORE }}
diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml
index 232602d99..4a180ddc9 100644
--- a/.github/workflows/reusable-android-build.yml
+++ b/.github/workflows/reusable-android-build.yml
@@ -27,6 +27,7 @@ jobs:
env:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
+ MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
steps:
diff --git a/.gitignore b/.gitignore
index 7842527ab..a8fe89f77 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,4 +29,6 @@ keystore.properties
# VS code
.vscode/settings.json
+
+# Secrets
/secrets.properties
diff --git a/README.md b/README.md
index ea42943a6..2ee238161 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,12 @@ You can help translate the app into your native language using [Crowdin](https:/
https://meshtastic.org/docs/development/android/
+Note: when building the `google` flavor locally you will need to supply your own [Google Maps Android SDK api key](https://developers.google.com/maps/documentation/android-sdk/get-api-key) `MAPS_API_KEY` in `local.properties` in order to use Google Maps.
+e.g.
+```properties
+MAPS_API_KEY=your_google_maps_api_key_here
+```
+
## Contributing guidelines
For detailed instructions on how to contribute, please see our [CONTRIBUTING.md](CONTRIBUTING.md) file.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fb1f0286b..e66756f91 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -15,6 +15,7 @@
* along with this program. If not, see .
*/
+import io.gitlab.arturbosch.detekt.Detekt
import java.io.FileInputStream
import java.util.Properties
@@ -54,7 +55,7 @@ android {
compileSdk = Configs.COMPILE_SDK
defaultConfig {
applicationId = Configs.APPLICATION_ID
- minSdk = Configs.MIN_SDK_VERSION
+ minSdk = Configs.MIN_SDK
targetSdk = Configs.TARGET_SDK
versionCode = System.getenv("VERSION_CODE")?.toIntOrNull() ?: 30630
testInstrumentationRunner = "com.geeksville.mesh.TestRunner"
@@ -232,8 +233,10 @@ dependencies {
implementation(libs.bundles.coil)
// OSM
- implementation(libs.bundles.osm)
- implementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
+ "fdroidImplementation"(libs.bundles.osm)
+ "fdroidImplementation"(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
+
+ "googleImplementation"(libs.bundles.maps.compose)
// ZXing
implementation(libs.zxing.android.embedded) { isTransitive = false }
@@ -288,6 +291,13 @@ repositories { maven { url = uri("https://jitpack.io") } }
detekt {
config.setFrom("../config/detekt/detekt.yml")
baseline = file("../config/detekt/detekt-baseline.xml")
+ source.setFrom(files("src/main/java", "src/google/java", "src/fdroid/java"))
+ parallel = true
+}
+
+secrets {
+ propertiesFileName = "secrets.properties"
+ defaultPropertiesFileName = "secrets.defaults.properties"
}
val googleServiceKeywords = listOf("crashlytics", "google", "datadog")
@@ -301,6 +311,22 @@ tasks.configureEach {
}
}
+tasks.withType {
+ reports {
+ xml.required = true
+ xml.outputLocation = file("build/reports/detekt/detekt.xml")
+ html.required = true
+ html.outputLocation = file("build/reports/detekt/detekt.html")
+ sarif.required = true
+ sarif.outputLocation = file("build/reports/detekt/detekt.sarif")
+ md.required = true
+ md.outputLocation = file("build/reports/detekt/detekt.md")
+ }
+ debug = true
+ include("**/*.kt")
+ include("**/*.kts")
+}
+
spotless {
ratchetFrom("origin/main")
kotlin {
diff --git a/app/src/fdroid/java/com/geeksville/mesh/analytics/NopAnalytics.kt b/app/src/fdroid/java/com/geeksville/mesh/analytics/NopAnalytics.kt
index 658c4370b..7171fe405 100644
--- a/app/src/fdroid/java/com/geeksville/mesh/analytics/NopAnalytics.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/analytics/NopAnalytics.kt
@@ -23,47 +23,36 @@ import com.geeksville.mesh.android.Logging
class DataPair(val name: String, valueIn: Any?) {
val value = valueIn ?: "null"
- /// An accumulating firebase event - only one allowed per event
+ // / An accumulating firebase event - only one allowed per event
constructor(d: Double) : this("BOGUS", d)
+
constructor(d: Int) : this("BOGUS", d)
}
-/**
- * Implement our analytics API using Firebase Analytics
- */
-@Suppress("UNUSED_PARAMETER")
-class NopAnalytics(context: Context) : AnalyticsProvider, Logging {
+/** Implement our analytics API using Firebase Analytics */
+@Suppress("UNUSED_PARAMETER", "EmptyFunctionBlock", "EmptyInitBlock")
+class NopAnalytics(context: Context) :
+ AnalyticsProvider,
+ Logging {
- init {
- }
+ init {}
- override fun setEnabled(on: Boolean) {
- }
+ override fun setEnabled(on: Boolean) {}
- override fun endSession() {
- }
+ override fun endSession() {}
- override fun trackLowValue(event: String, vararg properties: DataPair) {
- }
+ override fun trackLowValue(event: String, vararg properties: DataPair) {}
- override fun track(event: String, vararg properties: DataPair) {
- }
+ override fun track(event: String, vararg properties: DataPair) {}
- override fun startSession() {
- }
+ override fun startSession() {}
- override fun setUserInfo(vararg p: DataPair) {
- }
+ override fun setUserInfo(vararg p: DataPair) {}
- override fun increment(name: String, amount: Double) {
- }
+ override fun increment(name: String, amount: Double) {}
- /**
- * Send a google analytics screen view event
- */
- override fun sendScreenView(name: String) {
- }
+ /** Send a google analytics screen view event */
+ override fun sendScreenView(name: String) {}
- override fun endScreenView() {
- }
+ override fun endScreenView() {}
}
diff --git a/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt b/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt
index d728c1f02..8161bfddf 100644
--- a/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt
@@ -41,11 +41,18 @@ open class GeeksvilleApplication :
lateinit var analytics: AnalyticsProvider
}
+ val isGooglePlayAvailable: Boolean
+ get() {
+ return false
+ }
+
// / Are we running inside the testlab?
val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab") ?: null
- if (testLabSetting != null) info("Testlab is $testLabSetting")
+ if (testLabSetting != null) {
+ info("Testlab is $testLabSetting")
+ }
return "true" == testLabSetting
}
diff --git a/app/src/fdroid/java/com/geeksville/mesh/model/map/CustomTileSource.kt b/app/src/fdroid/java/com/geeksville/mesh/model/map/CustomTileSource.kt
new file mode 100644
index 000000000..c30714ed7
--- /dev/null
+++ b/app/src/fdroid/java/com/geeksville/mesh/model/map/CustomTileSource.kt
@@ -0,0 +1,209 @@
+/*
+ * 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.model.map
+
+import org.osmdroid.tileprovider.tilesource.ITileSource
+import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
+import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
+import org.osmdroid.util.MapTileIndex
+
+@Suppress("UnusedPrivateProperty")
+class CustomTileSource {
+
+ companion object {
+ val OPENWEATHER_RADAR =
+ OnlineTileSourceAuth(
+ "Open Weather Map",
+ 1,
+ 22,
+ 256,
+ ".png",
+ arrayOf("https://tile.openweathermap.org/map/"),
+ "Openweathermap",
+ TileSourcePolicy(
+ 4,
+ TileSourcePolicy.FLAG_NO_BULK or
+ TileSourcePolicy.FLAG_NO_PREVENTIVE or
+ TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
+ TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
+ ),
+ "precipitation",
+ "",
+ )
+ private val ESRI_IMAGERY =
+ object :
+ OnlineTileSourceBase(
+ "ESRI World Overview",
+ 1,
+ 20,
+ 256,
+ ".jpg",
+ arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"),
+ "Esri, Maxar, Earthstar Geographics, and the GIS User Community",
+ TileSourcePolicy(
+ 4,
+ TileSourcePolicy.FLAG_NO_BULK or
+ TileSourcePolicy.FLAG_NO_PREVENTIVE or
+ TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
+ TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
+ ),
+ ) {
+ override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
+ (
+ MapTileIndex.getZoom(pMapTileIndex).toString() +
+ "/" +
+ MapTileIndex.getY(pMapTileIndex) +
+ "/" +
+ MapTileIndex.getX(pMapTileIndex) +
+ mImageFilenameEnding
+ )
+ }
+
+ private val ESRI_WORLD_TOPO =
+ object :
+ OnlineTileSourceBase(
+ "ESRI World TOPO",
+ 1,
+ 20,
+ 256,
+ ".jpg",
+ arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"),
+ "Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
+ TileSourcePolicy(
+ 4,
+ TileSourcePolicy.FLAG_NO_BULK or
+ TileSourcePolicy.FLAG_NO_PREVENTIVE or
+ TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
+ TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
+ ),
+ ) {
+ override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
+ (
+ MapTileIndex.getZoom(pMapTileIndex).toString() +
+ "/" +
+ MapTileIndex.getY(pMapTileIndex) +
+ "/" +
+ MapTileIndex.getX(pMapTileIndex) +
+ mImageFilenameEnding
+ )
+ }
+ private val USGS_HYDRO_CACHE =
+ object :
+ OnlineTileSourceBase(
+ "USGS Hydro Cache",
+ 0,
+ 18,
+ 256,
+ "",
+ arrayOf("https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"),
+ "USGS",
+ TileSourcePolicy(
+ 2,
+ TileSourcePolicy.FLAG_NO_PREVENTIVE or
+ TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
+ TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
+ ),
+ ) {
+ override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
+ (
+ MapTileIndex.getZoom(pMapTileIndex).toString() +
+ "/" +
+ MapTileIndex.getY(pMapTileIndex) +
+ "/" +
+ MapTileIndex.getX(pMapTileIndex) +
+ mImageFilenameEnding
+ )
+ }
+ private val USGS_SHADED_RELIEF =
+ object :
+ OnlineTileSourceBase(
+ "USGS Shaded Relief Only",
+ 0,
+ 18,
+ 256,
+ "",
+ arrayOf(
+ "https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/",
+ ),
+ "USGS",
+ TileSourcePolicy(
+ 2,
+ TileSourcePolicy.FLAG_NO_PREVENTIVE or
+ TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
+ TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
+ ),
+ ) {
+ override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
+ (
+ MapTileIndex.getZoom(pMapTileIndex).toString() +
+ "/" +
+ MapTileIndex.getY(pMapTileIndex) +
+ "/" +
+ MapTileIndex.getX(pMapTileIndex) +
+ mImageFilenameEnding
+ )
+ }
+
+ /** WMS TILE SERVER More research is required to get this to function correctly with overlays */
+ val NOAA_RADAR_WMS =
+ NOAAWmsTileSource(
+ "Recent Weather Radar",
+ arrayOf(
+ "https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" +
+ "radar_meteo_imagery_nexrad_time/MapServer/WmsServer?",
+ ),
+ "1",
+ "1.1.0",
+ "",
+ "EPSG%3A3857",
+ "",
+ "image/png",
+ )
+
+ /** =============================================================================================== */
+ private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
+ private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
+ private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
+ private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
+ private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
+ val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
+
+ /** Source for each available [ITileSource] and their display names. */
+ val mTileSources: Map =
+ mapOf(
+ MAPNIK to "OpenStreetMap",
+ USGS_TOPO to "USGS TOPO",
+ OPEN_TOPO to "Open TOPO",
+ ESRI_WORLD_TOPO to "ESRI World TOPO",
+ USGS_SAT to "USGS Satellite",
+ ESRI_IMAGERY to "ESRI World Overview",
+ )
+
+ fun getTileSource(index: Int): ITileSource = mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE
+
+ fun getTileSource(aName: String): ITileSource {
+ for (tileSource: ITileSource in mTileSources.keys) {
+ if (tileSource.name().equals(aName)) {
+ return tileSource
+ }
+ }
+ throw IllegalArgumentException("No such tile source: $aName")
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt b/app/src/fdroid/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt
similarity index 66%
rename from app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt
rename to app/src/fdroid/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt
index d8281a8d4..d7a0b5fe4 100644
--- a/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt
@@ -37,39 +37,35 @@ class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) :
private const val EMOJI_FONT_SIZE_SP = 20f
}
- private val labelYOffsetPx by lazy {
- mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100
- }
+ private val labelYOffsetPx by lazy { mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100 }
- private val labelCornerRadiusPx by lazy {
- mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12
- }
+ private val labelCornerRadiusPx by lazy { mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12 }
private var nodeColor: Int = Color.GRAY
+
fun setNodeColors(colors: Pair) {
nodeColor = colors.second
}
private var precisionBits: Int? = null
+
fun setPrecisionBits(bits: Int) {
precisionBits = bits
}
@Suppress("MagicNumber")
- private fun getPrecisionMeters(): Double? {
- return when (precisionBits) {
- 10 -> 23345.484932
- 11 -> 11672.7369
- 12 -> 5836.36288
- 13 -> 2918.175876
- 14 -> 1459.0823719999053
- 15 -> 729.53562
- 16 -> 364.7622
- 17 -> 182.375556
- 18 -> 91.182212
- 19 -> 45.58554
- else -> null
- }
+ private fun getPrecisionMeters(): Double? = when (precisionBits) {
+ 10 -> 23345.484932
+ 11 -> 11672.7369
+ 12 -> 5836.36288
+ 13 -> 2918.175876
+ 14 -> 1459.0823719999053
+ 15 -> 729.53562
+ 16 -> 364.7622
+ 17 -> 182.375556
+ 18 -> 91.182212
+ 19 -> 45.58554
+ else -> null
}
private var onLongClickListener: (() -> Boolean)? = null
@@ -80,30 +76,27 @@ class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) :
private val mLabel = label
private val mEmoji = emoji
- private val textPaint = Paint().apply {
- textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f
- color = Color.DKGRAY
- isAntiAlias = true
- isFakeBoldText = true
- textAlign = Paint.Align.CENTER
- }
- private val emojiPaint = Paint().apply {
- textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f
- isAntiAlias = true
- textAlign = Paint.Align.CENTER
- }
+ private val textPaint =
+ Paint().apply {
+ textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f
+ color = Color.DKGRAY
+ isAntiAlias = true
+ isFakeBoldText = true
+ textAlign = Paint.Align.CENTER
+ }
+ private val emojiPaint =
+ Paint().apply {
+ textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f
+ isAntiAlias = true
+ textAlign = Paint.Align.CENTER
+ }
private val bgPaint = Paint().apply { color = Color.WHITE }
private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF {
val fontMetrics = textPaint.fontMetrics
val halfTextLength = textPaint.measureText(text) / 2 + 3
- return RectF(
- (x - halfTextLength),
- (y + fontMetrics.top),
- (x + halfTextLength),
- (y + fontMetrics.bottom)
- )
+ return RectF((x - halfTextLength), (y + fontMetrics.top), (x + halfTextLength), (y + fontMetrics.bottom))
}
override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean {
@@ -128,20 +121,18 @@ class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) :
mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) }
getPrecisionMeters()?.let { radius ->
- val polygon = Polygon(osmv).apply {
- points = Polygon.pointsAsCircle(
- position,
- radius
- )
- fillPaint.apply {
- color = nodeColor
- alpha = 48
+ val polygon =
+ Polygon(osmv).apply {
+ points = Polygon.pointsAsCircle(position, radius)
+ fillPaint.apply {
+ color = nodeColor
+ alpha = 48
+ }
+ outlinePaint.apply {
+ color = nodeColor
+ alpha = 64
+ }
}
- outlinePaint.apply {
- color = nodeColor
- alpha = 64
- }
- }
polygon.draw(c, osmv, false)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt b/app/src/fdroid/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt
similarity index 61%
rename from app/src/main/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt
rename to app/src/fdroid/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt
index ea2d78035..7d32d4427 100644
--- a/app/src/main/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt
@@ -37,33 +37,40 @@ open class NOAAWmsTileSource(
style: String?,
format: String,
) : OnlineTileSourceBase(
- aName, 0, 5, 256, "png", aBaseUrl, "", TileSourcePolicy(
+ aName,
+ 0,
+ 5,
+ 256,
+ "png",
+ aBaseUrl,
+ "",
+ TileSourcePolicy(
2,
- TileSourcePolicy.FLAG_NO_BULK
- or TileSourcePolicy.FLAG_NO_PREVENTIVE
- or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
- or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
- )
+ TileSourcePolicy.FLAG_NO_BULK or
+ TileSourcePolicy.FLAG_NO_PREVENTIVE or
+ TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
+ TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
+ ),
) {
// array indexes for array to hold bounding boxes.
- private val MINX = 0
- private val MAXX = 1
- private val MINY = 2
- private val MAXY = 3
+ private val minX = 0
+ private val maxX = 1
+ private val minY = 2
+ private val maxY = 3
// Web Mercator n/w corner of the map.
- private val TILE_ORIGIN = doubleArrayOf(-20037508.34789244, 20037508.34789244)
+ private val tileOrigin = doubleArrayOf(-20037508.34789244, 20037508.34789244)
- //array indexes for that data
- private val ORIG_X = 0
- private val ORIG_Y = 1 // "
+ // array indexes for that data
+ private val origX = 0
+ private val origY = 1 // "
// Size of square world map in meters, using WebMerc projection.
- private val MAP_SIZE = 20037508.34789244 * 2
+ private val mapSize = 20037508.34789244 * 2
private var layer = ""
private var version = "1.1.0"
- private var srs = "EPSG%3A3857" //used by geo server
+ private var srs = "EPSG%3A3857" // used by geo server
private var format = ""
private var time = ""
private var style: String? = null
@@ -80,26 +87,23 @@ open class NOAAWmsTileSource(
if (time != null) this.time = time
}
-// fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? {
-// var srs: String? = "EPSG:900913"
-// if (layer.srs.isNotEmpty()) {
-// srs = layer.srs[0]
-// }
-// return if (layer.styles.isEmpty()) {
-// WMSTileSource(
-// layer.name, arrayOf(endpoint.baseurl), layer.name,
-// endpoint.wmsVersion, srs, null, layer.pixelSize
-// )
-// } else WMSTileSource(
-// layer.name, arrayOf(endpoint.baseurl), layer.name,
-// endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize
-// )
-// }
+ // fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? {
+ // var srs: String? = "EPSG:900913"
+ // if (layer.srs.isNotEmpty()) {
+ // srs = layer.srs[0]
+ // }
+ // return if (layer.styles.isEmpty()) {
+ // WMSTileSource(
+ // layer.name, arrayOf(endpoint.baseurl), layer.name,
+ // endpoint.wmsVersion, srs, null, layer.pixelSize
+ // )
+ // } else WMSTileSource(
+ // layer.name, arrayOf(endpoint.baseurl), layer.name,
+ // endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize
+ // )
+ // }
-
- private fun tile2lon(x: Int, z: Int): Double {
- return x / 2.0.pow(z.toDouble()) * 360.0 - 180
- }
+ private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180
private fun tile2lat(y: Int, z: Int): Double {
val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble())
@@ -109,30 +113,26 @@ open class NOAAWmsTileSource(
// Return a web Mercator bounding box given tile x/y indexes and a zoom
// level.
private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
- val tileSize = MAP_SIZE / 2.0.pow(zoom.toDouble())
- val minx = TILE_ORIGIN[ORIG_X] + x * tileSize
- val maxx = TILE_ORIGIN[ORIG_X] + (x + 1) * tileSize
- val miny = TILE_ORIGIN[ORIG_Y] - (y + 1) * tileSize
- val maxy = TILE_ORIGIN[ORIG_Y] - y * tileSize
+ val tileSize = mapSize / 2.0.pow(zoom.toDouble())
+ val minx = tileOrigin[origX] + x * tileSize
+ val maxx = tileOrigin[origX] + (x + 1) * tileSize
+ val miny = tileOrigin[origY] - (y + 1) * tileSize
+ val maxy = tileOrigin[origY] - y * tileSize
val bbox = DoubleArray(4)
- bbox[MINX] = minx
- bbox[MINY] = miny
- bbox[MAXX] = maxx
- bbox[MAXY] = maxy
+ bbox[minX] = minx
+ bbox[minY] = miny
+ bbox[maxX] = maxx
+ bbox[maxY] = maxy
return bbox
}
- fun isForceHttps(): Boolean {
- return forceHttps
- }
+ fun isForceHttps(): Boolean = forceHttps
fun setForceHttps(forceHttps: Boolean) {
this.forceHttps = forceHttps
}
- fun isForceHttp(): Boolean {
- return forceHttp
- }
+ fun isForceHttp(): Boolean = forceHttp
fun setForceHttp(forceHttp: Boolean) {
this.forceHttp = forceHttp
@@ -143,8 +143,7 @@ open class NOAAWmsTileSource(
if (forceHttps) baseUrl = baseUrl.replace("http://", "https://")
if (forceHttp) baseUrl = baseUrl.replace("https://", "http://")
val sb = StringBuilder(baseUrl)
- if (!baseUrl.endsWith("&"))
- sb.append("service=WMS")
+ if (!baseUrl.endsWith("&")) sb.append("service=WMS")
sb.append("&request=GetMap")
sb.append("&version=").append(version)
sb.append("&layers=").append(layer)
@@ -156,15 +155,16 @@ open class NOAAWmsTileSource(
sb.append("&srs=").append(srs)
sb.append("&size=").append(getSize())
sb.append("&bbox=")
- val bbox = getBoundingBox(
- MapTileIndex.getX(pMapTileIndex),
- MapTileIndex.getY(pMapTileIndex),
- MapTileIndex.getZoom(pMapTileIndex)
- )
- sb.append(bbox[MINX]).append(",")
- sb.append(bbox[MINY]).append(",")
- sb.append(bbox[MAXX]).append(",")
- sb.append(bbox[MAXY])
+ val bbox =
+ getBoundingBox(
+ MapTileIndex.getX(pMapTileIndex),
+ MapTileIndex.getY(pMapTileIndex),
+ MapTileIndex.getZoom(pMapTileIndex),
+ )
+ sb.append(bbox[minX]).append(",")
+ sb.append(bbox[minY]).append(",")
+ sb.append(bbox[maxX]).append(",")
+ sb.append(bbox[maxY])
Log.i(IMapView.LOGTAG, sb.toString())
return sb.toString()
}
@@ -173,6 +173,5 @@ open class NOAAWmsTileSource(
val height = Resources.getSystem().displayMetrics.heightPixels
val width = Resources.getSystem().displayMetrics.widthPixels
return "$width,$height"
-
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt b/app/src/fdroid/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt
similarity index 61%
rename from app/src/main/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt
rename to app/src/fdroid/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt
index 57d48285a..a52ad7536 100644
--- a/app/src/main/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt
@@ -21,29 +21,28 @@ import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
+@Suppress("LongParameterList")
open class OnlineTileSourceAuth(
- aName: String,
- aZoomLevel: Int,
- aZoomMaxLevel: Int,
- aTileSizePixels: Int,
- aImageFileNameEnding: String,
- aBaseUrl: Array,
+ name: String,
+ zoomLevel: Int,
+ zoomMaxLevel: Int,
+ tileSizePixels: Int,
+ imageFileNameEnding: String,
+ baseUrl: Array,
pCopyright: String,
tileSourcePolicy: TileSourcePolicy,
layerName: String?,
- apiKey: String
-) :
- OnlineTileSourceBase(
- aName,
- aZoomLevel,
- aZoomMaxLevel,
- aTileSizePixels,
- aImageFileNameEnding,
- aBaseUrl,
- pCopyright,
- tileSourcePolicy
-
- ) {
+ apiKey: String,
+) : OnlineTileSourceBase(
+ name,
+ zoomLevel,
+ zoomMaxLevel,
+ tileSizePixels,
+ imageFileNameEnding,
+ baseUrl,
+ pCopyright,
+ tileSourcePolicy,
+) {
private var layerName = ""
private var apiKey = ""
@@ -52,13 +51,16 @@ open class OnlineTileSourceAuth(
this.layerName = layerName
}
this.apiKey = apiKey
-
}
- override fun getTileURLString(pMapTileIndex: Long): String {
- return "$baseUrl$layerName/" + (MapTileIndex.getZoom(pMapTileIndex)
- .toString() + "/" + MapTileIndex.getX(pMapTileIndex)
- .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
- .toString()) + mImageFilenameEnding + "?appId=$apiKey"
- }
-}
\ No newline at end of file
+ override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" +
+ (
+ MapTileIndex.getZoom(pMapTileIndex).toString() +
+ "/" +
+ MapTileIndex.getX(pMapTileIndex).toString() +
+ "/" +
+ MapTileIndex.getY(pMapTileIndex).toString()
+ ) +
+ mImageFilenameEnding +
+ "?appId=$apiKey"
+}
diff --git a/app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java b/app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java
similarity index 89%
rename from app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java
rename to app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java
index 05d07d5cf..ffb5d9e12 100644
--- a/app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java
+++ b/app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java
@@ -1,3 +1,20 @@
+/*
+ * 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.model.map.clustering;
import android.graphics.Bitmap;
@@ -5,13 +22,11 @@ import android.graphics.Canvas;
import android.graphics.Point;
import android.view.MotionEvent;
-import org.osmdroid.api.IGeoPoint;
-import org.osmdroid.bonuspack.kml.KmlFeature;
+import com.geeksville.mesh.model.map.MarkerWithLabel;
+
import org.osmdroid.util.BoundingBox;
-import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.MapView;
import org.osmdroid.views.overlay.Overlay;
-import com.geeksville.mesh.model.map.MarkerWithLabel;
import java.util.ArrayList;
import java.util.Iterator;
diff --git a/app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java b/app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java
similarity index 91%
rename from app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java
rename to app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java
index 954551175..2104f1697 100644
--- a/app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java
+++ b/app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java
@@ -1,3 +1,20 @@
+/*
+ * 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.model.map.clustering;
import android.content.Context;
@@ -10,11 +27,12 @@ import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.MotionEvent;
+import com.geeksville.mesh.model.map.MarkerWithLabel;
+
import org.osmdroid.bonuspack.R;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.MapView;
-import com.geeksville.mesh.model.map.MarkerWithLabel;
import java.util.ArrayList;
import java.util.Iterator;
diff --git a/app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java b/app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java
similarity index 71%
rename from app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java
rename to app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java
index 254020613..894aae57b 100644
--- a/app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java
+++ b/app/src/fdroid/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java
@@ -1,8 +1,26 @@
+/*
+ * 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.model.map.clustering;
+import com.geeksville.mesh.model.map.MarkerWithLabel;
+
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;
-import com.geeksville.mesh.model.map.MarkerWithLabel;
import java.util.ArrayList;
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/map/MapView.kt
similarity index 93%
rename from app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt
rename to app/src/fdroid/java/com/geeksville/mesh/ui/map/MapView.kt
index 63df92c70..89d4788fa 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/map/MapView.kt
@@ -45,7 +45,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableStateOf
@@ -210,10 +209,15 @@ private fun Context.purgeTileSource(onResult: (String) -> Unit) {
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
-fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Unit) {
+fun MapView(
+ uiViewModel: UIViewModel = viewModel(),
+ mapViewModel: MapViewModel = viewModel(),
+ navigateToNodeDetails: (Int) -> Unit,
+) {
var mapFilterExpanded by remember { mutableStateOf(false) }
- val mapFilterState by model.mapFilterStateFlow.collectAsState()
+ val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle()
var cacheEstimate by remember { mutableStateOf("") }
@@ -241,7 +245,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
fun loadOnlineTileSourceBase(): ITileSource {
- val id = model.mapStyleId
+ val id = mapViewModel.mapStyleId
debug("mapStyleId from prefs: $id")
return CustomTileSource.getTileSource(id).also {
zoomLevelMax = it.maximumZoomLevel.toDouble()
@@ -250,7 +254,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
}
val initialCameraView = remember {
- val nodes = model.nodeList.value
+ val nodes = mapViewModel.nodes.value
val nodesWithPosition = nodes.filter { it.validPosition != null }
val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) }
BoundingBox.fromGeoPoints(geoPoints)
@@ -262,7 +266,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
fun MapView.toggleMyLocation() {
if (context.gpsDisabled()) {
debug("Telling user we need location turned on for MyLocationNewOverlay")
- model.showSnackbar(R.string.location_disabled)
+ uiViewModel.showSnackBar(R.string.location_disabled)
return
}
debug("user clicked MyLocationNewOverlay ${myLocationOverlay == null}")
@@ -299,17 +303,16 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
}
}
- val nodes by model.nodeList.collectAsStateWithLifecycle()
- val waypoints by model.waypoints.collectAsStateWithLifecycle(emptyMap())
+ val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
+ val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24) }
fun MapView.onNodesChanged(nodes: Collection): List {
val nodesWithPosition = nodes.filter { it.validPosition != null }
- val ourNode = model.ourNodeInfo.value
- val gpsFormat = model.config.display.gpsFormat.number
- val displayUnits = model.config.display.units
- val mapFilterStateValue = model.mapFilterStateFlow.value // Access mapFilterState directly
+ val ourNode = uiViewModel.ourNodeInfo.value
+ val displayUnits = uiViewModel.config.display.units
+ val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
return@mapNotNull null
@@ -323,7 +326,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
snippet =
context.getString(
R.string.map_node_popup_details,
- node.gpsString(gpsFormat),
+ node.gpsString(),
formatAgo(node.lastHeard),
formatAgo(p.time),
if (node.batteryStr != "") node.batteryStr else "?",
@@ -354,13 +357,13 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
builder.setNeutralButton(R.string.cancel) { _, _ -> debug("User canceled marker delete dialog") }
builder.setNegativeButton(R.string.delete_for_me) { _, _ ->
debug("User deleted waypoint ${waypoint.id} for me")
- model.deleteWaypoint(waypoint.id)
+ uiViewModel.deleteWaypoint(waypoint.id)
}
- if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected()) {
+ if (waypoint.lockedTo in setOf(0, uiViewModel.myNodeNum ?: 0) && isConnected) {
builder.setPositiveButton(R.string.delete_for_everyone) { _, _ ->
debug("User deleted waypoint ${waypoint.id} for everyone")
- model.sendWaypoint(waypoint.copy { expire = 1 })
- model.deleteWaypoint(waypoint.id)
+ uiViewModel.sendWaypoint(waypoint.copy { expire = 1 })
+ uiViewModel.deleteWaypoint(waypoint.id)
}
}
val dialog = builder.show()
@@ -384,7 +387,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
debug("marker long pressed id=$id")
val waypoint = waypoints[id]?.data?.waypoint ?: return
// edit only when unlocked or lockedTo myNodeNum
- if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected()) {
+ if (waypoint.lockedTo in setOf(0, uiViewModel.myNodeNum ?: 0) && isConnected) {
showEditWaypointDialog = waypoint
} else {
showDeleteMarkerDialog(waypoint)
@@ -394,7 +397,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) {
context.getString(R.string.you)
} else {
- model.getUser(id).longName
+ uiViewModel.getUser(id).longName
}
@Composable
@@ -426,6 +429,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
"$hours hour${if (hours != 1) "s" else ""}"
}
}
+
else -> "${timeLeft / 86_400_000} day${if (timeLeft / 86_400_000 != 1L) "s" else ""}"
}
MarkerWithLabel(this, label, emoji).apply {
@@ -442,11 +446,9 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
}
}
- val isConnected = model.isConnectedStateFlow.collectAsStateWithLifecycle(false)
-
LaunchedEffect(showCurrentCacheInfo) {
if (!showCurrentCacheInfo) return@LaunchedEffect
- model.showSnackbar(R.string.calculating)
+ uiViewModel.showSnackBar(R.string.calculating)
val cacheManager = CacheManager(map)
val cacheCapacity = cacheManager.cacheCapacity()
val currentCacheUsage = cacheManager.currentCacheUsage()
@@ -477,7 +479,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
override fun longPressHelper(p: GeoPoint): Boolean {
performHapticFeedback()
- val enabled = isConnected.value && downloadRegionBoundingBox == null
+ val enabled = isConnected && downloadRegionBoundingBox == null
if (enabled) {
showEditWaypointDialog = waypoint {
@@ -555,11 +557,11 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
zoomLevelMax.toInt(),
cacheManagerCallback(
onTaskComplete = {
- model.showSnackbar(R.string.map_download_complete)
+ uiViewModel.showSnackBar(R.string.map_download_complete)
writer.onDetach()
},
onTaskFailed = { errors ->
- model.showSnackbar(context.getString(R.string.map_download_errors, errors))
+ uiViewModel.showSnackBar(context.getString(R.string.map_download_errors, errors))
writer.onDetach()
},
),
@@ -575,10 +577,10 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
val builder = MaterialAlertDialogBuilder(context)
val mapStyles: Array = CustomTileSource.mTileSources.values.toTypedArray()
- val mapStyleInt = model.mapStyleId
+ val mapStyleInt = mapViewModel.mapStyleId
builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which ->
debug("Set mapStyleId pref to $which")
- model.mapStyleId = which
+ mapViewModel.mapStyleId = which
dialog.dismiss()
map.setTileSource(loadOnlineTileSourceBase())
}
@@ -603,7 +605,8 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
map.generateBoxOverlay()
dialog.dismiss()
}
- 2 -> purgeTileSource { model.showSnackbar(it) }
+
+ 2 -> purgeTileSource { uiViewModel.showSnackBar(it) }
else -> dialog.dismiss()
}
}
@@ -676,12 +679,12 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
)
Checkbox(
checked = mapFilterState.onlyFavorites,
- onCheckedChange = { model.toggleOnlyFavorites() },
+ onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
- onClick = { model.toggleOnlyFavorites() },
+ onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
@@ -701,12 +704,12 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
)
Checkbox(
checked = mapFilterState.showWaypoints,
- onCheckedChange = { model.toggleShowWaypointsOnMap() },
+ onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
- onClick = { model.toggleShowWaypointsOnMap() },
+ onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
@@ -726,12 +729,12 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
)
Checkbox(
checked = mapFilterState.showPrecisionCircle,
- onCheckedChange = { model.toggleShowPrecisionCircleOnMap() },
+ onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
- onClick = { model.toggleShowPrecisionCircleOnMap() },
+ onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
}
}
@@ -764,12 +767,12 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un
onSendClicked = { waypoint ->
debug("User clicked send waypoint ${waypoint.id}")
showEditWaypointDialog = null
- model.sendWaypoint(
+ uiViewModel.sendWaypoint(
waypoint.copy {
- if (id == 0) id = model.generatePacketId() ?: return@EditWaypointDialog
+ if (id == 0) id = uiViewModel.generatePacketId() ?: return@EditWaypointDialog
if (name == "") name = "Dropped Pin"
if (expire == 0) expire = Int.MAX_VALUE
- lockedTo = if (waypoint.lockedTo != 0) model.myNodeNum ?: 0 else 0
+ lockedTo = if (waypoint.lockedTo != 0) uiViewModel.myNodeNum ?: 0 else 0
if (waypoint.icon == 0) icon = 128205
},
)
diff --git a/app/src/fdroid/java/com/geeksville/mesh/ui/map/MapViewModel.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/map/MapViewModel.kt
new file mode 100644
index 000000000..ddfa0f1a6
--- /dev/null
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/map/MapViewModel.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.ui.map
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.geeksville.mesh.database.NodeRepository
+import com.geeksville.mesh.database.PacketRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class MapViewModel
+@Inject
+constructor(
+ preferences: SharedPreferences,
+ packetRepository: PacketRepository,
+ nodeRepository: NodeRepository,
+) : BaseMapViewModel(preferences, nodeRepository, packetRepository) {
+
+ var mapStyleId: Int
+ get() = preferences.getInt(MAP_STYLE_ID, 0)
+ set(value) = preferences.edit { putInt(MAP_STYLE_ID, value) }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt
similarity index 99%
rename from app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt
rename to app/src/fdroid/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt
index 387533910..d7ae14cc9 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt
@@ -67,8 +67,6 @@ private fun PowerManager.WakeLock.safeRelease() {
}
}
-const val MAP_STYLE_ID = "map_style_id"
-
private const val MIN_ZOOM_LEVEL = 1.5
private const val MAX_ZOOM_LEVEL = 20.0
private const val DEFAULT_ZOOM_LEVEL = 15.0
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt
similarity index 81%
rename from app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt
rename to app/src/fdroid/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt
index 64e613d5e..1315cae91 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt
@@ -47,7 +47,8 @@ internal fun CacheLayout(
modifier: Modifier = Modifier,
) {
Column(
- modifier = modifier
+ modifier =
+ modifier
.fillMaxWidth()
.wrapContentHeight()
.background(color = MaterialTheme.colorScheme.background)
@@ -70,24 +71,13 @@ internal fun CacheLayout(
)
FlowRow(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
- Button(
- onClick = onCancelDownload,
- modifier = Modifier.weight(1f),
- ) {
- Text(
- text = stringResource(id = R.string.cancel),
- color = MaterialTheme.colorScheme.onPrimary,
- )
+ Button(onClick = onCancelDownload, modifier = Modifier.weight(1f)) {
+ Text(text = stringResource(id = R.string.cancel), color = MaterialTheme.colorScheme.onPrimary)
}
- Button(
- onClick = onExecuteJob,
- modifier = Modifier.weight(1f),
- ) {
+ Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) {
Text(
text = stringResource(id = R.string.map_start_download),
color = MaterialTheme.colorScheme.onPrimary,
@@ -100,9 +90,5 @@ internal fun CacheLayout(
@Preview(showBackground = true)
@Composable
private fun CacheLayoutPreview() {
- CacheLayout(
- cacheEstimate = "100 tiles",
- onExecuteJob = { },
- onCancelDownload = { },
- )
+ CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {})
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt
similarity index 82%
rename from app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt
rename to app/src/fdroid/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt
index a6f3b1e8d..16e035b4e 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt
@@ -34,25 +34,21 @@ import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
@Composable
-internal fun DownloadButton(
- enabled: Boolean,
- onClick: () -> Unit,
-) {
+internal fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
AnimatedVisibility(
visible = enabled,
- enter = slideInHorizontally(
+ enter =
+ slideInHorizontally(
initialOffsetX = { it },
- animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
+ animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
),
- exit = slideOutHorizontally(
+ exit =
+ slideOutHorizontally(
targetOffsetX = { it },
- animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
- )
+ animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
+ ),
) {
- FloatingActionButton(
- onClick = onClick,
- contentColor = MaterialTheme.colorScheme.primary,
- ) {
+ FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.map_download_region),
@@ -62,8 +58,8 @@ internal fun DownloadButton(
}
}
-//@Preview(showBackground = true)
-//@Composable
-//private fun DownloadButtonPreview() {
+// @Preview(showBackground = true)
+// @Composable
+// private fun DownloadButtonPreview() {
// DownloadButton(true, onClick = {})
-//}
+// }
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
similarity index 61%
rename from app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
rename to app/src/fdroid/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
index 13edce87a..8525e0349 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
@@ -85,6 +85,7 @@ internal fun EditWaypointDialog(
) {
var waypointInput by remember { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) R.string.waypoint_new else R.string.waypoint_edit
+
@Suppress("MagicNumber")
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
@@ -106,18 +107,20 @@ internal fun EditWaypointDialog(
// Determine locale-specific date format
val locale = Locale.getDefault()
- val dateFormat = if (locale.country == "US") {
- SimpleDateFormat("MM/dd/yyyy", locale)
- } else {
- SimpleDateFormat("dd/MM/yyyy", locale)
- }
+ val dateFormat =
+ if (locale.country == "US") {
+ SimpleDateFormat("MM/dd/yyyy", locale)
+ } else {
+ SimpleDateFormat("dd/MM/yyyy", locale)
+ }
// Check if 24-hour format is preferred
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
- val timeFormat = if (is24Hour) {
- SimpleDateFormat("HH:mm", locale)
- } else {
- SimpleDateFormat("hh:mm a", locale)
- }
+ val timeFormat =
+ if (is24Hour) {
+ SimpleDateFormat("HH:mm", locale)
+ } else {
+ SimpleDateFormat("hh:mm a", locale)
+ }
// State to hold selected date and time
var selectedDate by remember { mutableStateOf(dateFormat.format(calendar.time)) }
@@ -132,13 +135,12 @@ internal fun EditWaypointDialog(
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(title),
- style = MaterialTheme.typography.titleLarge.copy(
+ style =
+ MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 16.dp),
+ modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
EditTextPreference(
title = stringResource(R.string.name),
@@ -146,17 +148,16 @@ internal fun EditWaypointDialog(
maxSize = 29,
enabled = true,
isError = false,
- keyboardOptions = KeyboardOptions.Default.copy(
- keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
- ),
- keyboardActions = KeyboardActions(onDone = { }),
+ keyboardOptions =
+ KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = {}),
onValueChanged = { waypointInput = waypointInput.copy { name = it } },
trailingIcon = {
IconButton(onClick = { showEmojiPickerView = true }) {
Text(
text = String(Character.toChars(emoji)),
- modifier = Modifier
- .background(MaterialTheme.colorScheme.background, CircleShape)
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.background, CircleShape)
.padding(4.dp),
fontSize = 24.sp,
color = Color.Unspecified.copy(alpha = 1f),
@@ -170,63 +171,60 @@ internal fun EditWaypointDialog(
maxSize = 99,
enabled = true,
isError = false,
- keyboardOptions = KeyboardOptions.Default.copy(
- keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
- ),
- keyboardActions = KeyboardActions(onDone = { }),
- onValueChanged = { waypointInput = waypointInput.copy { description = it } }
+ keyboardOptions =
+ KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = {}),
+ onValueChanged = { waypointInput = waypointInput.copy { description = it } },
)
Row(
- modifier = Modifier
- .fillMaxWidth()
- .size(48.dp),
- verticalAlignment = Alignment.CenterVertically
+ modifier = Modifier.fillMaxWidth().size(48.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
- Image(
- imageVector = Icons.Default.Lock,
- contentDescription = stringResource(R.string.locked),
- )
+ Image(imageVector = Icons.Default.Lock, contentDescription = stringResource(R.string.locked))
Text(stringResource(R.string.locked))
Switch(
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentWidth(Alignment.End),
+ modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.lockedTo != 0,
- onCheckedChange = {
- waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 }
- }
+ onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } },
)
}
- val datePickerDialog = DatePickerDialog(
- context,
- { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
- selectedDate = "$selectedDay/${selectedMonth + 1}/$selectedYear"
- calendar.set(selectedYear, selectedMonth, selectedDay)
- epochTime = calendar.timeInMillis
- if (epochTime != null) {
- selectedDate = dateFormat.format(calendar.time)
- }
- }, year, month, day
- )
+ val datePickerDialog =
+ DatePickerDialog(
+ context,
+ { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
+ selectedDate = "$selectedDay/${selectedMonth + 1}/$selectedYear"
+ calendar.set(selectedYear, selectedMonth, selectedDay)
+ epochTime = calendar.timeInMillis
+ if (epochTime != null) {
+ selectedDate = dateFormat.format(calendar.time)
+ }
+ },
+ year,
+ month,
+ day,
+ )
- val timePickerDialog = android.app.TimePickerDialog(
- context,
- { _: TimePicker, selectedHour: Int, selectedMinute: Int ->
- selectedTime = String.format(Locale.getDefault(), "%02d:%02d", selectedHour, selectedMinute)
- calendar.set(Calendar.HOUR_OF_DAY, selectedHour)
- calendar.set(Calendar.MINUTE, selectedMinute)
- epochTime = calendar.timeInMillis
+ val timePickerDialog =
+ android.app.TimePickerDialog(
+ context,
+ { _: TimePicker, selectedHour: Int, selectedMinute: Int ->
+ selectedTime =
+ String.format(Locale.getDefault(), "%02d:%02d", selectedHour, selectedMinute)
+ calendar.set(Calendar.HOUR_OF_DAY, selectedHour)
+ calendar.set(Calendar.MINUTE, selectedMinute)
+ epochTime = calendar.timeInMillis
selectedTime = timeFormat.format(calendar.time)
- @Suppress("MagicNumber")
- waypointInput = waypointInput.copy { expire = (epochTime!! / 1000).toInt() }
- }, hour, minute, is24Hour
- )
+ @Suppress("MagicNumber")
+ waypointInput = waypointInput.copy { expire = (epochTime!! / 1000).toInt() }
+ },
+ hour,
+ minute,
+ is24Hour,
+ )
Row(
- modifier = Modifier
- .fillMaxWidth()
- .size(48.dp),
- verticalAlignment = Alignment.CenterVertically
+ modifier = Modifier.fillMaxWidth().size(48.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = Icons.Default.CalendarMonth,
@@ -234,19 +232,20 @@ internal fun EditWaypointDialog(
)
Text(stringResource(R.string.expires))
Switch(
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentWidth(Alignment.End),
+ modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
onCheckedChange = { isChecked ->
- waypointInput = waypointInput.copy {
- expire = if (isChecked) {
- @Suppress("MagicNumber")
- calendar.timeInMillis / 1000
- } else {
- Int.MAX_VALUE
- }.toInt()
- }
+ waypointInput =
+ waypointInput.copy {
+ expire =
+ if (isChecked) {
+ @Suppress("MagicNumber")
+ calendar.timeInMillis / 1000
+ } else {
+ Int.MAX_VALUE
+ }
+ .toInt()
+ }
if (isChecked) {
selectedDate = dateFormat.format(calendar.time)
selectedTime = timeFormat.format(calendar.time)
@@ -254,64 +253,59 @@ internal fun EditWaypointDialog(
selectedDate = ""
selectedTime = ""
}
- }
+ },
)
}
if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Button(onClick = { datePickerDialog.show() }) {
- Text(stringResource(R.string.date))
- }
- Text(
- modifier = Modifier.padding(top = 4.dp),
- text = "$selectedDate",
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- )
- }
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Button(onClick = { timePickerDialog.show() }) {
- Text(stringResource(R.string.time))
- }
- Text(
- modifier = Modifier.padding(top = 4.dp),
- text = "$selectedTime",
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- )
- }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = { datePickerDialog.show() }) { Text(stringResource(R.string.date)) }
+ Text(
+ modifier = Modifier.padding(top = 4.dp),
+ text = "$selectedDate",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
}
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = { timePickerDialog.show() }) { Text(stringResource(R.string.time)) }
+ Text(
+ modifier = Modifier.padding(top = 4.dp),
+ text = "$selectedTime",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
}
- } },
+ }
+ },
confirmButton = {
FlowRow(
modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.Center,
) {
- TextButton(
- modifier = modifier.weight(1f),
- onClick = onDismissRequest
- ) { Text(stringResource(R.string.cancel)) }
+ TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
+ Text(stringResource(R.string.cancel))
+ }
if (waypoint.id != 0) {
Button(
modifier = modifier.weight(1f),
onClick = { onDeleteClicked(waypointInput) },
enabled = waypointInput.name.isNotEmpty(),
- ) { Text(stringResource(R.string.delete)) }
+ ) {
+ Text(stringResource(R.string.delete))
+ }
+ }
+ Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
+ Text(stringResource(R.string.send))
}
- Button(
- modifier = modifier.weight(1f),
- onClick = { onSendClicked(waypointInput) },
- enabled = true,
- ) { Text(stringResource(R.string.send)) }
}
},
)
@@ -329,16 +323,17 @@ internal fun EditWaypointDialog(
private fun EditWaypointFormPreview() {
AppTheme {
EditWaypointDialog(
- waypoint = waypoint {
+ waypoint =
+ waypoint {
id = 123
name = "Test 123"
description = "This is only a test"
icon = 128169
expire = (System.currentTimeMillis() / 1000 + 8 * 3600).toInt()
},
- onSendClicked = { },
- onDeleteClicked = { },
- onDismissRequest = { },
+ onSendClicked = {},
+ onDeleteClicked = {},
+ onDismissRequest = {},
)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/MapButton.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/MapButton.kt
similarity index 75%
rename from app/src/main/java/com/geeksville/mesh/ui/map/components/MapButton.kt
rename to app/src/fdroid/java/com/geeksville/mesh/ui/map/components/MapButton.kt
index a9c3f727f..bcc75985f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/map/components/MapButton.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/map/components/MapButton.kt
@@ -37,7 +37,7 @@ fun MapButton(
icon: ImageVector,
@StringRes contentDescription: Int,
modifier: Modifier = Modifier,
- onClick: () -> Unit = {}
+ onClick: () -> Unit = {},
) {
MapButton(
icon = icon,
@@ -48,31 +48,14 @@ fun MapButton(
}
@Composable
-fun MapButton(
- icon: ImageVector,
- contentDescription: String?,
- modifier: Modifier = Modifier,
- onClick: () -> Unit = {}
-) {
- FloatingActionButton(
- onClick = onClick,
- modifier = modifier,
- ) {
- Icon(
- imageVector = icon,
- contentDescription = contentDescription,
- modifier = Modifier.size(24.dp),
- )
+fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
+ FloatingActionButton(onClick = onClick, modifier = modifier) {
+ Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@PreviewLightDark
@Composable
private fun MapButtonPreview() {
- AppTheme {
- MapButton(
- icon = Icons.Outlined.Layers,
- contentDescription = R.string.map_style_selection,
- )
- }
+ AppTheme { MapButton(icon = Icons.Outlined.Layers, contentDescription = R.string.map_style_selection) }
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeMap.kt b/app/src/fdroid/java/com/geeksville/mesh/ui/node/NodeMap.kt
similarity index 91%
rename from app/src/main/java/com/geeksville/mesh/ui/node/NodeMap.kt
rename to app/src/fdroid/java/com/geeksville/mesh/ui/node/NodeMap.kt
index 758e93b47..eb3814e8a 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeMap.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/ui/node/NodeMap.kt
@@ -27,6 +27,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
+import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle
import com.geeksville.mesh.util.addCopyright
import com.geeksville.mesh.util.addPolyline
@@ -35,15 +36,16 @@ import com.geeksville.mesh.util.addScaleBarOverlay
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
-private const val DegD = 1e-7
+private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(
+ @Suppress("UNUSED_PARAMETER") uiViewModel: UIViewModel = hiltViewModel(),
viewModel: MetricsViewModel = hiltViewModel(),
) {
val density = LocalDensity.current
val state by viewModel.state.collectAsStateWithLifecycle()
- val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DegD, it.longitudeI * DegD) }
+ val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
val mapView = rememberMapViewWithLifecycle(cameraView, viewModel.tileSource)
@@ -57,6 +59,6 @@ fun NodeMapScreen(
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(state.positionLogs) {}
- }
+ },
)
}
diff --git a/app/src/fdroid/java/com/geeksville/mesh/util/MapUtils.kt b/app/src/fdroid/java/com/geeksville/mesh/util/MapUtils.kt
new file mode 100644
index 000000000..385e97190
--- /dev/null
+++ b/app/src/fdroid/java/com/geeksville/mesh/util/MapUtils.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.util
+
+import org.osmdroid.util.BoundingBox
+import org.osmdroid.util.GeoPoint
+import kotlin.math.log2
+import kotlin.math.pow
+
+private const val DEGREES_IN_CIRCLE = 360.0
+private const val METERS_PER_DEGREE_LATITUDE = 111320.0
+private const val ZOOM_ADJUSTMENT_FACTOR = 0.8
+
+/**
+ * Calculates the zoom level required to fit the entire [BoundingBox] inside the map view.
+ *
+ * @return The zoom level as a Double value.
+ */
+fun BoundingBox.requiredZoomLevel(): Double {
+ val topLeft = GeoPoint(this.latNorth, this.lonWest)
+ val bottomRight = GeoPoint(this.latSouth, this.lonEast)
+ val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude))
+ val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude))
+ val requiredLatZoom = log2(DEGREES_IN_CIRCLE / (latLonHeight / METERS_PER_DEGREE_LATITUDE))
+ val requiredLonZoom = log2(DEGREES_IN_CIRCLE / (latLonWidth / METERS_PER_DEGREE_LATITUDE))
+ return maxOf(requiredLatZoom, requiredLonZoom) * ZOOM_ADJUSTMENT_FACTOR
+}
+
+/**
+ * Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor].
+ *
+ * @return A new [BoundingBox] with added [zoomFactor]. Example:
+ * ```
+ * // Setting the zoom level directly using setZoom()
+ * map.setZoom(14.0)
+ * val boundingBoxZoom14 = map.boundingBox
+ *
+ * // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0)
+ * val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0)
+ * ```
+ */
+fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox {
+ val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2)
+ val latDiff = latNorth - latSouth
+ val lonDiff = lonEast - lonWest
+
+ val newLatDiff = latDiff / (2.0.pow(zoomFactor))
+ val newLonDiff = lonDiff / (2.0.pow(zoomFactor))
+
+ return BoundingBox(
+ center.latitude + newLatDiff / 2,
+ center.longitude + newLonDiff / 2,
+ center.latitude - newLatDiff / 2,
+ center.longitude - newLonDiff / 2,
+ )
+}
diff --git a/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt b/app/src/fdroid/java/com/geeksville/mesh/util/MapViewExtensions.kt
similarity index 52%
rename from app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt
rename to app/src/fdroid/java/com/geeksville/mesh/util/MapViewExtensions.kt
index 82ebaf02b..0daea339f 100644
--- a/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/util/MapViewExtensions.kt
@@ -36,9 +36,7 @@ import org.osmdroid.views.overlay.ScaleBarOverlay
import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList
import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2
-/**
- * Adds copyright to map depending on what source is showing
- */
+/** Adds copyright to map depending on what source is showing */
fun MapView.addCopyright() {
if (overlays.none { it is CopyrightOverlay }) {
val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return
@@ -50,19 +48,21 @@ fun MapView.addCopyright() {
/**
* Create LatLong Grid line overlay
+ *
* @param enabled: turn on/off gridlines
*/
fun MapView.createLatLongGrid(enabled: Boolean) {
val latLongGridOverlay = LatLonGridlineOverlay2()
latLongGridOverlay.isEnabled = enabled
if (latLongGridOverlay.isEnabled) {
- val textPaint = Paint().apply {
- textSize = 40f
- color = Color.GRAY
- isAntiAlias = true
- isFakeBoldText = true
- textAlign = Paint.Align.CENTER
- }
+ val textPaint =
+ Paint().apply {
+ textSize = 40f
+ color = Color.GRAY
+ isAntiAlias = true
+ isFakeBoldText = true
+ textAlign = Paint.Align.CENTER
+ }
latLongGridOverlay.textPaint = textPaint
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
latLongGridOverlay.setLineWidth(3.0f)
@@ -73,75 +73,73 @@ fun MapView.createLatLongGrid(enabled: Boolean) {
fun MapView.addScaleBarOverlay(density: Density) {
if (overlays.none { it is ScaleBarOverlay }) {
- val scaleBarOverlay = ScaleBarOverlay(this).apply {
- setAlignBottom(true)
- with(density) {
- setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt())
- setTextSize(12.sp.toPx())
+ val scaleBarOverlay =
+ ScaleBarOverlay(this).apply {
+ setAlignBottom(true)
+ with(density) {
+ setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt())
+ setTextSize(12.sp.toPx())
+ }
+ textPaint.apply {
+ isAntiAlias = true
+ typeface = Typeface.DEFAULT_BOLD
+ }
}
- textPaint.apply {
- isAntiAlias = true
- typeface = Typeface.DEFAULT_BOLD
- }
- }
overlays.add(scaleBarOverlay)
}
}
-fun MapView.addPolyline(
- density: Density,
- geoPoints: List,
- onClick: () -> Unit
-): Polyline {
- val polyline = Polyline(this).apply {
- val borderPaint = Paint().apply {
- color = Color.BLACK
- isAntiAlias = true
- strokeWidth = with(density) { 10.dp.toPx() }
- style = Paint.Style.STROKE
- strokeJoin = Paint.Join.ROUND
- strokeCap = Paint.Cap.ROUND
- pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
+fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () -> Unit): Polyline {
+ val polyline =
+ Polyline(this).apply {
+ val borderPaint =
+ Paint().apply {
+ color = Color.BLACK
+ isAntiAlias = true
+ strokeWidth = with(density) { 10.dp.toPx() }
+ style = Paint.Style.STROKE
+ strokeJoin = Paint.Join.ROUND
+ strokeCap = Paint.Cap.ROUND
+ pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
+ }
+ outlinePaintLists.add(MonochromaticPaintList(borderPaint))
+ val fillPaint =
+ Paint().apply {
+ color = Color.WHITE
+ isAntiAlias = true
+ strokeWidth = with(density) { 6.dp.toPx() }
+ style = Paint.Style.FILL_AND_STROKE
+ strokeJoin = Paint.Join.ROUND
+ strokeCap = Paint.Cap.ROUND
+ pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
+ }
+ outlinePaintLists.add(MonochromaticPaintList(fillPaint))
+ setPoints(geoPoints)
+ setOnClickListener { _, _, _ ->
+ onClick()
+ true
+ }
}
- outlinePaintLists.add(MonochromaticPaintList(borderPaint))
- val fillPaint = Paint().apply {
- color = Color.WHITE
- isAntiAlias = true
- strokeWidth = with(density) { 6.dp.toPx() }
- style = Paint.Style.FILL_AND_STROKE
- strokeJoin = Paint.Join.ROUND
- strokeCap = Paint.Cap.ROUND
- pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
- }
- outlinePaintLists.add(MonochromaticPaintList(fillPaint))
- setPoints(geoPoints)
- setOnClickListener { _, _, _ ->
- onClick()
- true
- }
- }
overlays.add(polyline)
return polyline
}
-fun MapView.addPositionMarkers(
- positions: List,
- onClick: () -> Unit
-): List {
+fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation_24)
- val markers = positions.map {
- Marker(this).apply {
- icon = navIcon
- rotation = (it.groundTrack * 1e-5).toFloat()
- setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
- position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7)
- setOnMarkerClickListener { _, _ ->
- onClick()
- true
+ val markers =
+ positions.map {
+ Marker(this).apply {
+ icon = navIcon
+ rotation = (it.groundTrack * 1e-5).toFloat()
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
+ position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7)
+ setOnMarkerClickListener { _, _ ->
+ onClick()
+ true
+ }
}
}
- }
overlays.addAll(markers)
return markers
diff --git a/app/src/main/java/com/geeksville/mesh/util/SqlTileWriterExt.kt b/app/src/fdroid/java/com/geeksville/mesh/util/SqlTileWriterExt.kt
similarity index 57%
rename from app/src/main/java/com/geeksville/mesh/util/SqlTileWriterExt.kt
rename to app/src/fdroid/java/com/geeksville/mesh/util/SqlTileWriterExt.kt
index 72b67c827..896359d61 100644
--- a/app/src/main/java/com/geeksville/mesh/util/SqlTileWriterExt.kt
+++ b/app/src/fdroid/java/com/geeksville/mesh/util/SqlTileWriterExt.kt
@@ -21,25 +21,28 @@ import android.database.Cursor
import org.osmdroid.tileprovider.modules.DatabaseFileArchive
import org.osmdroid.tileprovider.modules.SqlTileWriter
-
/**
- * Extended the sqlite tile writer to have some additional query functions. A this point
- * it's unclear if there is a need to put these with the osmdroid-android library, thus they were
- * put here as more of an example.
- *
+ * Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need
+ * to put these with the osmdroid-android library, thus they were put here as more of an example.
*
* created on 12/21/2016.
*
* @author Alex O'Ree
* @since 5.6.2
*/
-class SqlTileWriterExt() : SqlTileWriter() {
- fun select(rows: Int, offset: Int): Cursor? {
- return this.db?.rawQuery(
- "select " + DatabaseFileArchive.COLUMN_KEY + "," + COLUMN_EXPIRES + "," + DatabaseFileArchive.COLUMN_PROVIDER + " from " + DatabaseFileArchive.TABLE + " limit ? offset ?",
- arrayOf(rows.toString() + "", offset.toString() + "")
- )
- }
+class SqlTileWriterExt : SqlTileWriter() {
+ fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery(
+ "select " +
+ DatabaseFileArchive.COLUMN_KEY +
+ "," +
+ COLUMN_EXPIRES +
+ "," +
+ DatabaseFileArchive.COLUMN_PROVIDER +
+ " from " +
+ DatabaseFileArchive.TABLE +
+ " limit ? offset ?",
+ arrayOf(rows.toString() + "", offset.toString() + ""),
+ )
/**
* gets all the tiles sources that we have tiles for in the cache database and their counts
@@ -55,16 +58,27 @@ class SqlTileWriterExt() : SqlTileWriter() {
}
var cur: Cursor? = null
try {
- cur = db.rawQuery(
- "select "
- + DatabaseFileArchive.COLUMN_PROVIDER
- + ",count(*) "
- + ",min(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
- + ",max(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
- + ",sum(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
- + "from " + DatabaseFileArchive.TABLE + " "
- + "group by " + DatabaseFileArchive.COLUMN_PROVIDER, null
- )
+ cur =
+ db.rawQuery(
+ "select " +
+ DatabaseFileArchive.COLUMN_PROVIDER +
+ ",count(*) " +
+ ",min(length(" +
+ DatabaseFileArchive.COLUMN_TILE +
+ ")) " +
+ ",max(length(" +
+ DatabaseFileArchive.COLUMN_TILE +
+ ")) " +
+ ",sum(length(" +
+ DatabaseFileArchive.COLUMN_TILE +
+ ")) " +
+ "from " +
+ DatabaseFileArchive.TABLE +
+ " " +
+ "group by " +
+ DatabaseFileArchive.COLUMN_PROVIDER,
+ null,
+ )
while (cur.moveToNext()) {
val c = SourceCount()
c.source = cur.getString(0)
@@ -82,12 +96,11 @@ class SqlTileWriterExt() : SqlTileWriter() {
}
return ret
}
- val rowCountExpired: Long
- get() = getRowCount(
- "$COLUMN_EXPIRES", arrayOf(System.currentTimeMillis().toString())
- )
- class SourceCount() {
+ val rowCountExpired: Long
+ get() = getRowCount("$COLUMN_EXPIRES", arrayOf(System.currentTimeMillis().toString()))
+
+ class SourceCount {
var rowCount: Long = 0
var source: String? = null
var sizeTotal: Long = 0
@@ -95,4 +108,4 @@ class SqlTileWriterExt() : SqlTileWriter() {
var sizeMax: Long = 0
var sizeAvg: Long = 0
}
-}
\ No newline at end of file
+}
diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml
new file mode 100644
index 000000000..41e38c351
--- /dev/null
+++ b/app/src/google/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt
index 4e7a962ca..f81cbdd6f 100644
--- a/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt
+++ b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt
@@ -41,7 +41,9 @@ class MeshUtilApplication : GeeksvilleApplication() {
crashlytics.setUserId(pref.getInstallId()) // be able to group all bugs per anonymous user
fun sendCrashReports() {
- if (isAnalyticsAllowed) crashlytics.sendUnsentReports()
+ if (isAnalyticsAllowed) {
+ crashlytics.sendUnsentReports()
+ }
}
// Send any old reports if user approves
diff --git a/app/src/google/java/com/geeksville/mesh/analytics/FirebaseAnalytics.kt b/app/src/google/java/com/geeksville/mesh/analytics/FirebaseAnalytics.kt
index a8ed6a10f..b8456e377 100644
--- a/app/src/google/java/com/geeksville/mesh/analytics/FirebaseAnalytics.kt
+++ b/app/src/google/java/com/geeksville/mesh/analytics/FirebaseAnalytics.kt
@@ -21,23 +21,24 @@ import android.content.Context
import android.os.Bundle
import com.geeksville.mesh.android.AppPrefs
import com.geeksville.mesh.android.Logging
+import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
import com.google.firebase.analytics.logEvent
-import com.google.firebase.Firebase
class DataPair(val name: String, valueIn: Any?) {
val value = valueIn ?: "null"
- /// An accumulating firebase event - only one allowed per event
+ // / An accumulating firebase event - only one allowed per event
constructor(d: Double) : this(FirebaseAnalytics.Param.VALUE, d)
+
constructor(d: Int) : this(FirebaseAnalytics.Param.VALUE, d)
}
-/**
- * Implement our analytics API using Firebase Analytics
- */
-class FirebaseAnalytics(context: Context) : AnalyticsProvider, Logging {
+/** Implement our analytics API using Firebase Analytics */
+class FirebaseAnalytics(context: Context) :
+ AnalyticsProvider,
+ Logging {
val t = Firebase.analytics
@@ -85,12 +86,10 @@ class FirebaseAnalytics(context: Context) : AnalyticsProvider, Logging {
}
override fun increment(name: String, amount: Double) {
- //Mint.logEvent("$name increment")
+ // Mint.logEvent("$name increment")
}
- /**
- * Send a google analytics screen view event
- */
+ /** Send a google analytics screen view event */
override fun sendScreenView(name: String) {
debug("Analytics: start screen $name")
t.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
diff --git a/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt b/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt
index 82fec64d7..85aae080a 100644
--- a/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt
+++ b/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt
@@ -57,7 +57,6 @@ import com.suddenh4x.ratingdialog.AppRating
import io.opentracing.util.GlobalTracer
import timber.log.Timber
-/** Created by kevinh on 1/4/15. */
open class GeeksvilleApplication :
Application(),
Logging {
@@ -70,7 +69,9 @@ open class GeeksvilleApplication :
val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab")
- if (testLabSetting != null) info("Testlab is $testLabSetting")
+ if (testLabSetting != null) {
+ info("Testlab is $testLabSetting")
+ }
return "true" == testLabSetting
}
@@ -109,6 +110,7 @@ open class GeeksvilleApplication :
fun askToRate(activity: AppCompatActivity) {
if (!isGooglePlayAvailable) return
+ @Suppress("MaxLineLength")
exceptionReporter {
// we don't want to crash our app because of bugs in this optional feature
AppRating.Builder(activity)
diff --git a/app/src/google/java/com/geeksville/mesh/di/MapModule.kt b/app/src/google/java/com/geeksville/mesh/di/MapModule.kt
new file mode 100644
index 000000000..3351d083e
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/di/MapModule.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.di
+
+import com.geeksville.mesh.repository.map.CustomTileProviderRepository
+import com.geeksville.mesh.repository.map.SharedPreferencesCustomTileProviderRepository
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.serialization.json.Json
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class IoDispatcher
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DefaultDispatcher
+
+@Module
+@InstallIn(SingletonComponent::class)
+object MapModule {
+
+ @Provides @DefaultDispatcher
+ fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @Provides @IoDispatcher
+ fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ // Serialization Provider (from original SerializationModule)
+ @Provides @Singleton
+ fun provideJson(): Json = Json { prettyPrint = false }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class MapRepositoryModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindCustomTileProviderRepository(
+ impl: SharedPreferencesCustomTileProviderRepository,
+ ): CustomTileProviderRepository
+}
diff --git a/app/src/google/java/com/geeksville/mesh/model/map/CustomTileSource.kt b/app/src/google/java/com/geeksville/mesh/model/map/CustomTileSource.kt
new file mode 100644
index 000000000..3b6e6fb46
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/model/map/CustomTileSource.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.model.map
+
+class CustomTileSource {
+
+ companion object {
+ fun getTileSource(index: Int) {
+ index
+ }
+ }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/repository/map/CustomTileProviderRepository.kt b/app/src/google/java/com/geeksville/mesh/repository/map/CustomTileProviderRepository.kt
new file mode 100644
index 000000000..fc58be2ca
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/repository/map/CustomTileProviderRepository.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.repository.map
+
+import com.geeksville.mesh.ui.map.CustomTileProviderConfig
+import kotlinx.coroutines.flow.Flow
+
+interface CustomTileProviderRepository {
+ fun getCustomTileProviders(): Flow>
+
+ suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
+
+ suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
+
+ suspend fun deleteCustomTileProvider(configId: String)
+
+ suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
+}
diff --git a/app/src/google/java/com/geeksville/mesh/repository/map/SharedPreferencesCustomTileProviderRepository.kt b/app/src/google/java/com/geeksville/mesh/repository/map/SharedPreferencesCustomTileProviderRepository.kt
new file mode 100644
index 000000000..a82817730
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/repository/map/SharedPreferencesCustomTileProviderRepository.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.repository.map
+
+import android.content.Context
+import androidx.core.content.edit
+import com.geeksville.mesh.di.IoDispatcher
+import com.geeksville.mesh.ui.map.CustomTileProviderConfig
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val KEY_CUSTOM_TILE_PROVIDERS = "custom_tile_providers"
+private const val PREFS_NAME_TILE = "map_tile_provider_prefs"
+
+@Singleton
+class SharedPreferencesCustomTileProviderRepository
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val json: Json,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+) : CustomTileProviderRepository {
+
+ private val sharedPreferences = context.getSharedPreferences(PREFS_NAME_TILE, Context.MODE_PRIVATE)
+
+ private val customTileProvidersStateFlow = MutableStateFlow>(emptyList())
+
+ init {
+ loadDataFromPrefs()
+ }
+
+ private fun loadDataFromPrefs() {
+ val jsonString = sharedPreferences.getString(KEY_CUSTOM_TILE_PROVIDERS, null)
+ if (jsonString != null) {
+ try {
+ customTileProvidersStateFlow.value = json.decodeFromString>(jsonString)
+ } catch (e: SerializationException) {
+ Timber.tag("TileRepo").e(e, "Error deserializing tile providers")
+ customTileProvidersStateFlow.value = emptyList()
+ }
+ } else {
+ customTileProvidersStateFlow.value = emptyList()
+ }
+ }
+
+ private suspend fun saveDataToPrefs(providers: List) {
+ withContext(ioDispatcher) {
+ try {
+ val jsonString = json.encodeToString(providers)
+ sharedPreferences.edit { putString(KEY_CUSTOM_TILE_PROVIDERS, jsonString) }
+ } catch (e: SerializationException) {
+ Timber.tag("TileRepo").e(e, "Error serializing tile providers")
+ }
+ }
+ }
+
+ override fun getCustomTileProviders(): Flow> =
+ customTileProvidersStateFlow.asStateFlow()
+
+ override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
+ val newList = customTileProvidersStateFlow.value + config
+ customTileProvidersStateFlow.value = newList
+ saveDataToPrefs(newList)
+ }
+
+ override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
+ val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
+ customTileProvidersStateFlow.value = newList
+ saveDataToPrefs(newList)
+ }
+
+ override suspend fun deleteCustomTileProvider(configId: String) {
+ val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
+ customTileProvidersStateFlow.value = newList
+ saveDataToPrefs(newList)
+ }
+
+ override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
+ customTileProvidersStateFlow.value.find { it.id == configId }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/LocationHandler.kt b/app/src/google/java/com/geeksville/mesh/ui/map/LocationHandler.kt
new file mode 100644
index 000000000..28accd205
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/LocationHandler.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.ui.map
+
+import android.Manifest
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts
+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.core.content.ContextCompat
+import com.geeksville.mesh.android.BuildUtils.debug
+import com.google.android.gms.common.api.ResolvableApiException
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.LocationSettingsRequest
+import com.google.android.gms.location.Priority
+
+private const val INTERVAL_MILLIS = 10000L
+
+@Suppress("LongMethod")
+@Composable
+fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
+ val context = LocalContext.current
+ var localHasPermission by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED,
+ )
+ }
+
+ val requestLocationPermissionLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted ->
+ localHasPermission = isGranted
+ // Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via
+ // onPermissionResult
+ // if permission is granted. If not granted, immediately report false.
+ if (!isGranted) {
+ onPermissionResult(false)
+ }
+ }
+
+ val locationSettingsLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ debug("Location settings changed by user.")
+ // User has enabled location services or improved accuracy.
+ onPermissionResult(true) // Settings are now adequate, and permission was already granted.
+ } else {
+ debug("Location settings change cancelled by user.")
+ // User chose not to change settings. The permission itself is still granted,
+ // but the experience might be degraded. For the purpose of enabling map features,
+ // we consider this as success if the core permission is there.
+ // If stricter handling is needed (e.g., block feature if settings not optimal),
+ // this logic might change.
+ onPermissionResult(localHasPermission)
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ // Initial permission check
+ when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
+ PackageManager.PERMISSION_GRANTED -> {
+ if (!localHasPermission) {
+ localHasPermission = true
+ }
+ // If permission is already granted, proceed to check location settings.
+ // The LaunchedEffect(localHasPermission) will handle this.
+ // No need to call onPermissionResult(true) here yet, let settings check complete.
+ }
+
+ else -> {
+ // Request permission if not granted. The launcher's callback will update localHasPermission.
+ requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ }
+ }
+
+ LaunchedEffect(localHasPermission) {
+ // Handles logic after permission status is known/updated
+ if (localHasPermission) {
+ // Permission is granted, now check location settings
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build()
+
+ val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
+
+ val client = LocationServices.getSettingsClient(context)
+ val task = client.checkLocationSettings(builder.build())
+
+ task.addOnSuccessListener {
+ debug("Location settings are satisfied.")
+ onPermissionResult(true) // Permission granted and settings are good
+ }
+
+ task.addOnFailureListener { exception ->
+ if (exception is ResolvableApiException) {
+ try {
+ val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
+ locationSettingsLauncher.launch(intentSenderRequest)
+ // Result of this launch will be handled by locationSettingsLauncher's callback
+ } catch (sendEx: ActivityNotFoundException) {
+ debug("Error launching location settings resolution ${sendEx.message}.")
+ onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
+ }
+ } else {
+ debug("Location settings are not satisfiable.${exception.message}")
+ onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
+ }
+ }
+ } else {
+ // If permission is not granted, report false.
+ // This case is primarily handled by the requestLocationPermissionLauncher's callback
+ // if the initial state was denied, or if user denies it.
+ onPermissionResult(false)
+ }
+ }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt b/app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt
new file mode 100644
index 000000000..69beee3b6
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt
@@ -0,0 +1,701 @@
+/*
+ * 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 .
+ */
+
+@file:Suppress("MagicNumber")
+
+package com.geeksville.mesh.ui.map
+
+import android.content.Intent
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.location.Location
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.animation.core.animate
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.filled.TripOrigin
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingToolbarDefaults
+import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
+import androidx.compose.material3.FloatingToolbarExitDirection.Companion.End
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberFloatingToolbarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.createBitmap
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
+import com.geeksville.mesh.MeshProtos
+import com.geeksville.mesh.MeshProtos.Position
+import com.geeksville.mesh.MeshProtos.Waypoint
+import com.geeksville.mesh.R
+import com.geeksville.mesh.android.BuildUtils.debug
+import com.geeksville.mesh.android.BuildUtils.warn
+import com.geeksville.mesh.copy
+import com.geeksville.mesh.model.Node
+import com.geeksville.mesh.model.UIViewModel
+import com.geeksville.mesh.ui.map.components.ClusterItemsListDialog
+import com.geeksville.mesh.ui.map.components.CustomMapLayersSheet
+import com.geeksville.mesh.ui.map.components.CustomTileProviderManagerSheet
+import com.geeksville.mesh.ui.map.components.EditWaypointDialog
+import com.geeksville.mesh.ui.map.components.MapControlsOverlay
+import com.geeksville.mesh.ui.map.components.NodeClusterMarkers
+import com.geeksville.mesh.ui.map.components.WaypointMarkers
+import com.geeksville.mesh.ui.metrics.HEADING_DEG
+import com.geeksville.mesh.ui.metrics.formatPositionTime
+import com.geeksville.mesh.ui.node.DEG_D
+import com.geeksville.mesh.ui.node.components.NodeChip
+import com.geeksville.mesh.util.formatAgo
+import com.geeksville.mesh.util.metersIn
+import com.geeksville.mesh.util.mpsToKmph
+import com.geeksville.mesh.util.mpsToMph
+import com.geeksville.mesh.util.toString
+import com.geeksville.mesh.waypoint
+import com.google.android.gms.maps.CameraUpdateFactory
+import com.google.android.gms.maps.model.BitmapDescriptor
+import com.google.android.gms.maps.model.BitmapDescriptorFactory
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.JointType
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.LatLngBounds
+import com.google.android.gms.maps.model.RoundCap
+import com.google.maps.android.clustering.ClusterItem
+import com.google.maps.android.compose.CameraMoveStartedReason
+import com.google.maps.android.compose.ComposeMapColorScheme
+import com.google.maps.android.compose.GoogleMap
+import com.google.maps.android.compose.MapEffect
+import com.google.maps.android.compose.MapProperties
+import com.google.maps.android.compose.MapType
+import com.google.maps.android.compose.MapUiSettings
+import com.google.maps.android.compose.MapsComposeExperimentalApi
+import com.google.maps.android.compose.MarkerComposable
+import com.google.maps.android.compose.MarkerInfoWindowComposable
+import com.google.maps.android.compose.Polyline
+import com.google.maps.android.compose.TileOverlay
+import com.google.maps.android.compose.rememberCameraPositionState
+import com.google.maps.android.compose.rememberUpdatedMarkerState
+import com.google.maps.android.compose.widgets.DisappearingScaleBar
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.text.DateFormat
+
+private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
+
+@Suppress("ReturnCount")
+private fun filterNodeTrack(nodeTrack: List?): List {
+ if (nodeTrack.isNullOrEmpty()) return emptyList()
+
+ val sortedTrack = nodeTrack.sortedBy { it.time }
+ if (sortedTrack.size <= 2) return sortedTrack.map { it }
+
+ val filteredPoints = mutableListOf()
+ var lastAddedPointProto = sortedTrack.first()
+ filteredPoints.add(lastAddedPointProto)
+
+ for (i in 1 until sortedTrack.size - 1) {
+ val currentPointProto = sortedTrack[i]
+ val currentPoint = currentPointProto.toLatLng()
+ val lastAddedPoint = lastAddedPointProto.toLatLng()
+ val distanceResults = FloatArray(1)
+ Location.distanceBetween(
+ lastAddedPoint.latitude,
+ lastAddedPoint.longitude,
+ currentPoint.latitude,
+ currentPoint.longitude,
+ distanceResults,
+ )
+ if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS) {
+ filteredPoints.add(currentPointProto)
+ lastAddedPointProto = currentPointProto
+ }
+ }
+
+ val lastOriginalPointProto = sortedTrack.last()
+ if (filteredPoints.last() != lastOriginalPointProto) {
+ val distanceResults = FloatArray(1)
+ val lastAddedPoint = lastAddedPointProto.toLatLng()
+ val lastOriginalPoint = lastOriginalPointProto.toLatLng()
+ Location.distanceBetween(
+ lastAddedPoint.latitude,
+ lastAddedPoint.longitude,
+ lastOriginalPoint.latitude,
+ lastOriginalPoint.longitude,
+ distanceResults,
+ )
+ if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS || filteredPoints.size == 1) {
+ filteredPoints.add(lastAddedPointProto)
+ }
+ }
+ return filteredPoints
+}
+
+@Suppress("CyclomaticComplexMethod", "LongMethod")
+@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MapView(
+ uiViewModel: UIViewModel,
+ mapViewModel: MapViewModel = hiltViewModel(),
+ navigateToNodeDetails: (Int) -> Unit,
+ focusedNodeNum: Int? = null,
+ nodeTrack: List? = null,
+) {
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
+ var hasLocationPermission by remember { mutableStateOf(false) }
+ val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
+
+ LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted }
+
+ val kmlFilePickerLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == android.app.Activity.RESULT_OK) {
+ result.data?.data?.let { uri ->
+ val fileName = uri.getFileName(context)
+ mapViewModel.addMapLayer(uri, fileName)
+ }
+ }
+ }
+
+ var mapFilterMenuExpanded by remember { mutableStateOf(false) }
+ val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ val ourNodeInfo by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
+ var editingWaypoint by remember { mutableStateOf(null) }
+ val savedCameraPosition by mapViewModel.cameraPosition.collectAsStateWithLifecycle()
+
+ val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
+ val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
+
+ var mapTypeMenuExpanded by remember { mutableStateOf(false) }
+ var showCustomTileManagerSheet by remember { mutableStateOf(false) }
+
+ val defaultLatLng = LatLng(0.0, 0.0)
+ val cameraPositionState = rememberCameraPositionState {
+ position =
+ savedCameraPosition?.let {
+ CameraPosition(LatLng(it.targetLat, it.targetLng), it.zoom, it.tilt, it.bearing)
+ } ?: CameraPosition.fromLatLngZoom(defaultLatLng, 7f)
+ }
+
+ val floatingToolbarState = rememberFloatingToolbarState()
+ val exitAlwaysScrollBehavior =
+ FloatingToolbarDefaults.exitAlwaysScrollBehavior(exitDirection = End, state = floatingToolbarState)
+
+ LaunchedEffect(cameraPositionState.isMoving, floatingToolbarState.offsetLimit) {
+ val targetOffset =
+ if (cameraPositionState.isMoving) {
+ floatingToolbarState.offsetLimit
+ } else {
+ mapViewModel.onCameraPositionChanged(cameraPositionState.position)
+ 0f
+ }
+ if (floatingToolbarState.offset != targetOffset) {
+ if (targetOffset == 0f || floatingToolbarState.offsetLimit != 0f) {
+ launch {
+ animate(initialValue = floatingToolbarState.offset, targetValue = targetOffset) { value, _ ->
+ floatingToolbarState.offset = value
+ }
+ }
+ }
+ }
+ }
+
+ val allNodes by
+ mapViewModel.nodes
+ .map { nodes -> nodes.filter { node -> node.validPosition != null } }
+ .collectAsStateWithLifecycle(listOf())
+ val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
+ val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
+
+ var hasZoomed by rememberSaveable { mutableStateOf(false) }
+
+ LaunchedEffect(allNodes, displayableWaypoints, nodeTrack) {
+ if ((hasZoomed) || cameraPositionState.cameraMoveStartedReason != CameraMoveStartedReason.NO_MOVEMENT_YET) {
+ if (!hasZoomed) hasZoomed = true
+ return@LaunchedEffect
+ }
+
+ val pointsToBound: List =
+ when {
+ !nodeTrack.isNullOrEmpty() -> nodeTrack.map { it.toLatLng() }
+
+ allNodes.isNotEmpty() || displayableWaypoints.isNotEmpty() ->
+ allNodes.mapNotNull { it.toLatLng() } + displayableWaypoints.map { it.toLatLng() }
+
+ else -> emptyList()
+ }
+
+ if (pointsToBound.isNotEmpty()) {
+ val bounds = LatLngBounds.builder().apply { pointsToBound.forEach(::include) }.build()
+
+ val padding = if (!pointsToBound.isEmpty()) 100 else 48
+
+ try {
+ cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding))
+ hasZoomed = true
+ } catch (e: IllegalStateException) {
+ warn("MapView Could not animate to bounds: ${e.message}")
+ }
+ }
+ }
+ val filteredNodes =
+ if (mapFilterState.onlyFavorites) {
+ allNodes.filter { it.isFavorite || it.num == ourNodeInfo?.num }
+ } else {
+ allNodes
+ }
+
+ val nodeClusterItems =
+ filteredNodes.map { node ->
+ val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D)
+ NodeClusterItem(
+ node = node,
+ nodePosition = latLng,
+ nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}",
+ nodeSnippet = "${node.user.longName}",
+ )
+ }
+ val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle()
+ val theme by uiViewModel.theme.collectAsStateWithLifecycle()
+ val dark =
+ when (theme) {
+ AppCompatDelegate.MODE_NIGHT_YES -> true
+ AppCompatDelegate.MODE_NIGHT_NO -> false
+ AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
+ else -> isSystemInDarkTheme()
+ }
+ val mapColorScheme =
+ when (dark) {
+ true -> ComposeMapColorScheme.DARK
+ else -> ComposeMapColorScheme.LIGHT
+ }
+
+ var showLayersBottomSheet by remember { mutableStateOf(false) }
+
+ val onAddLayerClicked = {
+ val intent =
+ Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ val mimeTypes = arrayOf("application/vnd.google-earth.kml+xml", "application/vnd.google-earth.kmz")
+ putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
+ }
+ kmlFilePickerLauncher.launch(intent)
+ }
+ val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
+ val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
+
+ val effectiveGoogleMapType =
+ if (currentCustomTileProviderUrl != null) {
+ MapType.NONE
+ } else {
+ selectedGoogleMapType
+ }
+
+ var showClusterItemsDialog by remember { mutableStateOf?>(null) }
+
+ Scaffold(modifier = Modifier.nestedScroll(exitAlwaysScrollBehavior)) { paddingValues ->
+ Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
+ GoogleMap(
+ mapColorScheme = mapColorScheme,
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState,
+ uiSettings =
+ MapUiSettings(
+ zoomControlsEnabled = true,
+ mapToolbarEnabled = true,
+ compassEnabled = true,
+ myLocationButtonEnabled = hasLocationPermission,
+ rotationGesturesEnabled = true,
+ scrollGesturesEnabled = true,
+ tiltGesturesEnabled = true,
+ zoomGesturesEnabled = true,
+ ),
+ properties =
+ MapProperties(mapType = effectiveGoogleMapType, isMyLocationEnabled = hasLocationPermission),
+ onMapLongClick = { latLng ->
+ if (isConnected) {
+ val newWaypoint = waypoint {
+ latitudeI = (latLng.latitude / DEG_D).toInt()
+ longitudeI = (latLng.longitude / DEG_D).toInt()
+ }
+ editingWaypoint = newWaypoint
+ }
+ },
+ ) {
+ key(currentCustomTileProviderUrl) {
+ currentCustomTileProviderUrl?.let { url ->
+ mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
+ TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
+ }
+ }
+ }
+
+ if (nodeTrack != null && focusedNodeNum != null) {
+ val originalLatLngs =
+ nodeTrack.sortedBy { it.time }.map { LatLng(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
+ val filteredLatLngs = filterNodeTrack(nodeTrack)
+
+ val focusedNode = allNodes.find { it.num == focusedNodeNum }
+ val polylineColor = focusedNode?.colors?.let { Color(it.first) } ?: Color.Blue
+ if (originalLatLngs.isNotEmpty()) {
+ focusedNode?.let {
+ MarkerComposable(
+ state = rememberUpdatedMarkerState(position = originalLatLngs.first()),
+ zIndex = 1f,
+ ) {
+ NodeChip(node = it, isThisNode = false, isConnected = false, onAction = {})
+ }
+ }
+ }
+
+ val pointsForMarkers =
+ if (originalLatLngs.isNotEmpty() && focusedNode != null) {
+ filteredLatLngs.drop(1)
+ } else {
+ filteredLatLngs
+ }
+
+ pointsForMarkers.forEachIndexed { index, position ->
+ val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
+ val dateFormat = remember {
+ DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
+ }
+ val alpha = 1 - (index.toFloat() / pointsForMarkers.size.toFloat())
+ MarkerInfoWindowComposable(
+ state = markerState,
+ title = stringResource(R.string.position),
+ snippet = formatAgo(position.time),
+ zIndex = alpha,
+ infoContent = {
+ PositionInfoWindowContent(
+ position = position,
+ dateFormat = dateFormat,
+ displayUnits = displayUnits,
+ )
+ },
+ ) {
+ Icon(
+ imageVector = androidx.compose.material.icons.Icons.Default.TripOrigin,
+ contentDescription = stringResource(R.string.track_point),
+ modifier = Modifier.padding(8.dp),
+ tint = polylineColor.copy(alpha = alpha),
+ )
+ }
+ }
+ if (filteredLatLngs.size > 1) {
+ Polyline(
+ points = filteredLatLngs.map { it.toLatLng() },
+ jointType = JointType.ROUND,
+ endCap = RoundCap(),
+ startCap = RoundCap(),
+ geodesic = true,
+ color = polylineColor,
+ width = 8f,
+ zIndex = 0f,
+ )
+ }
+ } else {
+ NodeClusterMarkers(
+ nodeClusterItems = nodeClusterItems,
+ mapFilterState = mapFilterState,
+ navigateToNodeDetails = navigateToNodeDetails,
+ onClusterClick = { cluster ->
+ val items = cluster.items.toList()
+ val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
+
+ if (allSameLocation) {
+ showClusterItemsDialog = items
+ } else {
+ val bounds = LatLngBounds.builder()
+ cluster.items.forEach { bounds.include(it.position) }
+ coroutineScope.launch {
+ cameraPositionState.animate(
+ CameraUpdateFactory.newLatLngBounds(bounds.build(), 100),
+ )
+ }
+ debug("Cluster clicked! $cluster")
+ }
+ true
+ },
+ )
+ }
+
+ WaypointMarkers(
+ displayableWaypoints = displayableWaypoints,
+ mapFilterState = mapFilterState,
+ myNodeNum = uiViewModel.myNodeNum ?: 0,
+ isConnected = isConnected,
+ unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
+ onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
+ )
+
+ MapEffect(mapLayers) { map ->
+ mapLayers.forEach { layerItem ->
+ mapViewModel.loadKmlLayerIfNeeded(map, layerItem)?.let { kmlLayer ->
+ if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
+ kmlLayer.addLayerToMap()
+ } else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
+ kmlLayer.removeLayerFromMap()
+ }
+ }
+ }
+ }
+ }
+
+ DisappearingScaleBar(cameraPositionState = cameraPositionState)
+
+ editingWaypoint?.let { waypointToEdit ->
+ EditWaypointDialog(
+ waypoint = waypointToEdit,
+ onSendClicked = { updatedWp ->
+ var finalWp = updatedWp
+ if (updatedWp.id == 0) {
+ finalWp = finalWp.copy { id = uiViewModel.generatePacketId() ?: 0 }
+ }
+ if (updatedWp.icon == 0) {
+ finalWp = finalWp.copy { icon = 0x1F4CD }
+ }
+
+ uiViewModel.sendWaypoint(finalWp)
+ editingWaypoint = null
+ },
+ onDeleteClicked = { wpToDelete ->
+ if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) {
+ val deleteMarkerWp = wpToDelete.copy { expire = 1 }
+ uiViewModel.sendWaypoint(deleteMarkerWp)
+ }
+ uiViewModel.deleteWaypoint(wpToDelete.id)
+ editingWaypoint = null
+ },
+ onDismissRequest = { editingWaypoint = null },
+ )
+ }
+
+ MapControlsOverlay(
+ modifier = Modifier.align(Alignment.CenterEnd).offset(x = -ScreenOffset),
+ mapFilterMenuExpanded = mapFilterMenuExpanded,
+ onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false },
+ onToggleMapFilterMenu = { mapFilterMenuExpanded = true },
+ mapViewModel = mapViewModel,
+ mapTypeMenuExpanded = mapTypeMenuExpanded,
+ onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false },
+ onToggleMapTypeMenu = { mapTypeMenuExpanded = true },
+ onManageLayersClicked = { showLayersBottomSheet = true },
+ onManageCustomTileProvidersClicked = {
+ mapTypeMenuExpanded = false
+ showCustomTileManagerSheet = true
+ },
+ showFilterButton = focusedNodeNum == null,
+ scrollBehavior = exitAlwaysScrollBehavior,
+ )
+ }
+ if (showLayersBottomSheet) {
+ ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
+ CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked)
+ }
+ }
+ showClusterItemsDialog?.let {
+ ClusterItemsListDialog(
+ items = it,
+ onDismiss = { showClusterItemsDialog = null },
+ onItemClick = { item ->
+ navigateToNodeDetails(item.node.num)
+ showClusterItemsDialog = null
+ },
+ )
+ }
+ if (showCustomTileManagerSheet) {
+ ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
+ CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
+ }
+ }
+ }
+}
+
+internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
+ String(Character.toChars(unicodeCodePoint))
+} catch (e: IllegalArgumentException) {
+ Timber.w(e, "Invalid unicode code point: $unicodeCodePoint")
+ "\uD83D\uDCCD"
+}
+
+internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
+ val unicodeEmoji = convertIntToEmoji(icon)
+ val paint =
+ Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ textSize = 64f
+ color = android.graphics.Color.BLACK
+ textAlign = Paint.Align.CENTER
+ }
+
+ val baseline = -paint.ascent()
+ val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt()
+ val height = (baseline + paint.descent() + 0.5f).toInt()
+ val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(image)
+ canvas.drawText(unicodeEmoji, width / 2f, baseline, paint)
+
+ return BitmapDescriptorFactory.fromBitmap(image)
+}
+
+@Suppress("NestedBlockDepth")
+fun Uri.getFileName(context: android.content.Context): String {
+ var name = this.lastPathSegment ?: "layer_${System.currentTimeMillis()}"
+ 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
+}
+
+data class NodeClusterItem(val node: Node, val nodePosition: LatLng, val nodeTitle: String, val nodeSnippet: String) :
+ ClusterItem {
+ override fun getPosition(): LatLng = nodePosition
+
+ override fun getTitle(): String = nodeTitle
+
+ override fun getSnippet(): String = nodeSnippet
+
+ override fun getZIndex(): Float? = null
+
+ fun getPrecisionMeters(): Double? {
+ val precisionMap =
+ mapOf(
+ 10 to 23345.484932,
+ 11 to 11672.7369,
+ 12 to 5836.36288,
+ 13 to 2918.175876,
+ 14 to 1459.0823719999053,
+ 15 to 729.53562,
+ 16 to 364.7622,
+ 17 to 182.375556,
+ 18 to 91.182212,
+ 19 to 45.58554,
+ )
+ return precisionMap[this.node.position.precisionBits]
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+@Suppress("LongMethod")
+private fun PositionInfoWindowContent(
+ position: Position,
+ dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM),
+ displayUnits: DisplayUnits = DisplayUnits.METRIC,
+) {
+ @Composable
+ fun PositionRow(label: String, value: String) {
+ Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(label, style = MaterialTheme.typography.labelMedium)
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(value, style = MaterialTheme.typography.labelMediumEmphasized)
+ }
+ }
+
+ Card {
+ Column(modifier = Modifier.padding(8.dp)) {
+ PositionRow(
+ label = stringResource(R.string.latitude),
+ value = "%.5f".format(position.latitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
+ )
+
+ PositionRow(
+ label = stringResource(R.string.longitude),
+ value = "%.5f".format(position.longitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
+ )
+
+ PositionRow(label = stringResource(R.string.sats), value = position.satsInView.toString())
+
+ PositionRow(
+ label = stringResource(R.string.alt),
+ value = position.altitude.metersIn(displayUnits).toString(displayUnits),
+ )
+
+ PositionRow(label = stringResource(R.string.speed), value = speedFromPosition(position, displayUnits))
+
+ PositionRow(
+ label = stringResource(R.string.heading),
+ value = "%.0f°".format(position.groundTrack * HEADING_DEG),
+ )
+
+ PositionRow(label = stringResource(R.string.timestamp), value = formatPositionTime(position, dateFormat))
+ }
+ }
+}
+
+@Composable
+private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
+ val speedInMps = position.groundSpeed
+ val mpsText = "%d m/s".format(speedInMps)
+ val speedText =
+ if (speedInMps > 10) {
+ when (displayUnits) {
+ DisplayUnits.METRIC -> "%.1f Km/h".format(position.groundSpeed.mpsToKmph())
+ DisplayUnits.IMPERIAL -> "%.1f mph".format(position.groundSpeed.mpsToMph())
+ else -> mpsText // Fallback or handle UNRECOGNIZED
+ }
+ } else {
+ mpsText
+ }
+ return speedText
+}
+
+private fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
+
+private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
+
+private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt b/app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt
new file mode 100644
index 000000000..c9fd3e3ce
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt
@@ -0,0 +1,383 @@
+/*
+ * 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.ui.map
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.net.Uri
+import android.util.Log
+import androidx.lifecycle.viewModelScope
+import com.geeksville.mesh.ConfigProtos
+import com.geeksville.mesh.android.BuildUtils.debug
+import com.geeksville.mesh.database.NodeRepository
+import com.geeksville.mesh.database.PacketRepository
+import com.geeksville.mesh.repository.datastore.RadioConfigRepository
+import com.geeksville.mesh.repository.map.CustomTileProviderRepository
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.TileProvider
+import com.google.android.gms.maps.model.UrlTileProvider
+import com.google.maps.android.compose.MapType
+import com.google.maps.android.data.kml.KmlLayer
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.net.MalformedURLException
+import java.net.URL
+import java.util.UUID
+import javax.inject.Inject
+
+private const val TILE_SIZE = 256
+
+@Serializable
+data class MapCameraPosition(
+ val targetLat: Double,
+ val targetLng: Double,
+ val zoom: Float,
+ val tilt: Float,
+ val bearing: Float,
+)
+
+@Suppress("TooManyFunctions")
+@HiltViewModel
+class MapViewModel
+@Inject
+constructor(
+ private val application: Application,
+ preferences: SharedPreferences,
+ nodeRepository: NodeRepository,
+ packetRepository: PacketRepository,
+ radioConfigRepository: RadioConfigRepository,
+ private val customTileProviderRepository: CustomTileProviderRepository,
+) : BaseMapViewModel(preferences, nodeRepository, packetRepository) {
+
+ private val _errorFlow = MutableSharedFlow()
+ val errorFlow: SharedFlow = _errorFlow.asSharedFlow()
+
+ val customTileProviderConfigs: StateFlow> =
+ customTileProviderRepository
+ .getCustomTileProviders()
+ .stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = emptyList())
+
+ private val _selectedCustomTileProviderUrl = MutableStateFlow(null)
+ val selectedCustomTileProviderUrl: StateFlow = _selectedCustomTileProviderUrl.asStateFlow()
+
+ private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
+ val selectedGoogleMapType: StateFlow = _selectedGoogleMapType.asStateFlow()
+
+ private val _cameraPosition = MutableStateFlow(null)
+
+ val cameraPosition: StateFlow = _cameraPosition.asStateFlow()
+
+ val displayUnits =
+ radioConfigRepository.deviceProfileFlow
+ .mapNotNull { it.config.display.units }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC,
+ )
+
+ fun onCameraPositionChanged(cameraPosition: CameraPosition) {
+ _cameraPosition.value =
+ MapCameraPosition(
+ targetLat = cameraPosition.target.latitude,
+ targetLng = cameraPosition.target.longitude,
+ zoom = cameraPosition.zoom,
+ tilt = cameraPosition.tilt,
+ bearing = cameraPosition.bearing,
+ )
+ }
+
+ fun addCustomTileProvider(name: String, urlTemplate: String) {
+ viewModelScope.launch {
+ if (name.isBlank() || urlTemplate.isBlank() || !isValidTileUrlTemplate(urlTemplate)) {
+ _errorFlow.emit("Invalid name or URL template for custom tile provider.")
+ return@launch
+ }
+ if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
+ _errorFlow.emit("Custom tile provider with name '$name' already exists.")
+ return@launch
+ }
+
+ val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate)
+ customTileProviderRepository.addCustomTileProvider(newConfig)
+ }
+ }
+
+ fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
+ viewModelScope.launch {
+ if (
+ configToUpdate.name.isBlank() ||
+ configToUpdate.urlTemplate.isBlank() ||
+ !isValidTileUrlTemplate(configToUpdate.urlTemplate)
+ ) {
+ _errorFlow.emit("Invalid name or URL template for updating custom tile provider.")
+ return@launch
+ }
+ val existingConfigs = customTileProviderConfigs.value
+ if (
+ existingConfigs.any {
+ it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true)
+ }
+ ) {
+ _errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.")
+ return@launch
+ }
+
+ customTileProviderRepository.updateCustomTileProvider(configToUpdate)
+
+ val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id)
+ if (
+ _selectedCustomTileProviderUrl.value != null &&
+ originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value
+ ) {
+ // No change needed if URL didn't change, or handle if it did
+ } else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) {
+ val currentlySelectedConfig =
+ customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value }
+ if (currentlySelectedConfig?.id == configToUpdate.id) {
+ _selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate
+ }
+ }
+ }
+ }
+
+ fun removeCustomTileProvider(configId: String) {
+ viewModelScope.launch {
+ val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
+ customTileProviderRepository.deleteCustomTileProvider(configId)
+
+ if (configToRemove != null && _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate) {
+ _selectedCustomTileProviderUrl.value = null
+ }
+ }
+ }
+
+ fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
+ if (config != null) {
+ if (!isValidTileUrlTemplate(config.urlTemplate)) {
+ Log.w("MapViewModel", "Attempted to select invalid URL template: ${config.urlTemplate}")
+ _selectedCustomTileProviderUrl.value = null
+ return
+ }
+ _selectedCustomTileProviderUrl.value = config.urlTemplate
+ } else {
+ _selectedCustomTileProviderUrl.value = null
+ }
+ }
+
+ fun setSelectedGoogleMapType(mapType: MapType) {
+ _selectedGoogleMapType.value = mapType
+ if (_selectedCustomTileProviderUrl.value != null) {
+ _selectedCustomTileProviderUrl.value = null
+ }
+ }
+
+ fun createUrlTileProvider(urlString: String): TileProvider? {
+ if (!isValidTileUrlTemplate(urlString)) {
+ Log.e("MapViewModel", "Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
+ 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) {
+ Log.e("MapViewModel", "Malformed URL: $formattedUrl", e)
+ null
+ }
+ }
+ }
+ }
+
+ private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
+ urlTemplate.contains("{x}", ignoreCase = true) &&
+ urlTemplate.contains("{y}", ignoreCase = true)
+
+ private val _mapLayers = MutableStateFlow>(emptyList())
+ val mapLayers: StateFlow> = _mapLayers.asStateFlow()
+
+ init {
+ loadPersistedLayers()
+ }
+
+ private fun loadPersistedLayers() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val layersDir = File(application.filesDir, "map_layers")
+ if (layersDir.exists() && layersDir.isDirectory) {
+ val persistedLayerFiles = layersDir.listFiles()
+
+ if (persistedLayerFiles != null) {
+ val loadedItems =
+ persistedLayerFiles.mapNotNull { file ->
+ if (file.isFile) {
+ MapLayerItem(
+ name = file.nameWithoutExtension,
+ uri = Uri.fromFile(file),
+ isVisible = true,
+ )
+ } else {
+ null
+ }
+ }
+ _mapLayers.value = loadedItems
+ if (loadedItems.isNotEmpty()) {
+ Log.i("MapViewModel", "Loaded ${loadedItems.size} persisted map layers.")
+ }
+ }
+ } else {
+ Log.i("MapViewModel", "Map layers directory does not exist. No layers loaded.")
+ }
+ } catch (e: Exception) {
+ Log.e("MapViewModel", "Error loading persisted map layers", e)
+ _mapLayers.value = emptyList()
+ }
+ }
+ }
+
+ fun addMapLayer(uri: Uri, fileName: String?) {
+ viewModelScope.launch {
+ val layerName = fileName ?: "Layer ${mapLayers.value.size + 1}"
+ val localFileUri = copyFileToInternalStorage(uri, fileName ?: "layer_${UUID.randomUUID()}")
+
+ if (localFileUri != null) {
+ val newItem = MapLayerItem(name = layerName, uri = localFileUri)
+ _mapLayers.value = _mapLayers.value + newItem
+ } else {
+ Log.e("MapViewModel", "Failed to copy KML/KMZ file to internal storage.")
+ }
+ }
+ }
+
+ private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
+ try {
+ val inputStream = application.contentResolver.openInputStream(uri)
+ val directory = File(application.filesDir, "map_layers")
+ if (!directory.exists()) {
+ directory.mkdirs()
+ }
+ val outputFile = File(directory, fileName)
+ val outputStream = FileOutputStream(outputFile)
+
+ inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } }
+ Uri.fromFile(outputFile)
+ } catch (e: IOException) {
+ Log.e("MapViewModel", "Error copying file to internal storage", e)
+ null
+ }
+ }
+
+ fun toggleLayerVisibility(layerId: String) {
+ _mapLayers.value = _mapLayers.value.map { if (it.id == layerId) it.copy(isVisible = !it.isVisible) else it }
+ }
+
+ fun removeMapLayer(layerId: String) {
+ viewModelScope.launch {
+ val layerToRemove = _mapLayers.value.find { it.id == layerId }
+ layerToRemove?.kmlLayerData?.removeLayerFromMap()
+ layerToRemove?.uri?.let { uri -> deleteFileFromInternalStorage(uri) }
+ _mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
+ }
+ }
+
+ private suspend fun deleteFileFromInternalStorage(uri: Uri) {
+ withContext(Dispatchers.IO) {
+ try {
+ val file = File(uri.path ?: return@withContext)
+ if (file.exists()) {
+ file.delete()
+ }
+ } catch (e: Exception) {
+ Log.e("MapViewModel", "Error deleting file from internal storage", e)
+ }
+ }
+ }
+
+ @Suppress("Recycle")
+ suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
+ val uriToLoad = layerItem.uri ?: return null
+ val stream =
+ withContext(Dispatchers.IO) {
+ try {
+ application.contentResolver.openInputStream(uriToLoad)
+ } catch (_: Exception) {
+ debug("MapViewModel: Error opening InputStream from URI: $uriToLoad")
+ null
+ }
+ }
+ return stream
+ }
+
+ suspend fun loadKmlLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem): KmlLayer? {
+ if (layerItem.kmlLayerData != null) {
+ return layerItem.kmlLayerData
+ }
+
+ return try {
+ getInputStreamFromUri(layerItem)?.use { inputStream ->
+ val kmlLayer = KmlLayer(map, inputStream, application.applicationContext)
+ _mapLayers.update { currentLayers ->
+ currentLayers.map { if (it.id == layerItem.id) it.copy(kmlLayerData = kmlLayer) else it }
+ }
+ kmlLayer
+ }
+ } catch (e: Exception) {
+ Log.e("MapViewModel", "Error loading KML for ${layerItem.uri}", e)
+ null
+ }
+ }
+}
+
+data class MapLayerItem(
+ val id: String = UUID.randomUUID().toString(),
+ val name: String,
+ val uri: Uri? = null,
+ var isVisible: Boolean = true,
+ var kmlLayerData: KmlLayer? = null,
+)
+
+@Serializable
+data class CustomTileProviderConfig(
+ val id: String = UUID.randomUUID().toString(),
+ val name: String,
+ val urlTemplate: String,
+)
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/ClusterItemsListDialog.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/ClusterItemsListDialog.kt
new file mode 100644
index 000000000..1f71db2e3
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/ClusterItemsListDialog.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.ui.map.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.geeksville.mesh.R
+import com.geeksville.mesh.ui.map.NodeClusterItem
+import com.geeksville.mesh.ui.node.components.NodeChip
+
+@Composable
+fun ClusterItemsListDialog(
+ items: List,
+ onDismiss: () -> Unit,
+ onItemClick: (NodeClusterItem) -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(text = stringResource(R.string.nodes_at_this_location)) },
+ text = {
+ // Use a LazyColumn for potentially long lists of items
+ LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
+ items(items, key = { it.node.num }) { item ->
+ ClusterDialogListItem(item = item, onClick = { onItemClick(item) })
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.okay)) } },
+ )
+}
+
+@Composable
+private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ ListItem(
+ leadingContent = { NodeChip(node = item.node, enabled = false, isThisNode = false, isConnected = false) {} },
+ headlineContent = { Text(item.nodeTitle) },
+ supportingContent = {
+ if (item.nodeSnippet.isNotBlank()) {
+ Text(item.nodeSnippet)
+ }
+ },
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items
+ )
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/CustomMapLayersSheet.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/CustomMapLayersSheet.kt
new file mode 100644
index 000000000..c86613e00
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/CustomMapLayersSheet.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.ui.map.components
+
+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.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.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Button
+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.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.geeksville.mesh.R
+import com.geeksville.mesh.ui.map.MapLayerItem
+
+@Suppress("LongMethod")
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun CustomMapLayersSheet(
+ mapLayers: List,
+ onToggleVisibility: (String) -> Unit,
+ onRemoveLayer: (String) -> Unit,
+ onAddLayerClicked: () -> Unit,
+) {
+ LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
+ item {
+ Text(
+ modifier = Modifier.Companion.padding(16.dp),
+ text = stringResource(R.string.manage_map_layers),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ HorizontalDivider()
+ }
+
+ if (mapLayers.isEmpty()) {
+ item {
+ Text(
+ modifier = Modifier.Companion.padding(16.dp),
+ text = stringResource(R.string.no_map_layers_loaded),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ } else {
+ items(mapLayers, key = { it.id }) { layer ->
+ ListItem(
+ headlineContent = { Text(layer.name) },
+ trailingContent = {
+ Row {
+ IconButton(onClick = { onToggleVisibility(layer.id) }) {
+ Icon(
+ imageVector =
+ if (layer.isVisible) {
+ Icons.Filled.Visibility
+ } else {
+ Icons.Filled.VisibilityOff
+ },
+ contentDescription =
+ stringResource(
+ if (layer.isVisible) {
+ R.string.hide_layer
+ } else {
+ R.string.show_layer
+ },
+ ),
+ )
+ }
+ IconButton(onClick = { onRemoveLayer(layer.id) }) {
+ Icon(
+ imageVector = Icons.Filled.Delete,
+ contentDescription = stringResource(R.string.remove_layer),
+ )
+ }
+ }
+ },
+ )
+ HorizontalDivider()
+ }
+ }
+ item {
+ Button(modifier = Modifier.Companion.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) {
+ Text(stringResource(R.string.add_layer))
+ }
+ }
+ }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/CustomTileProviderManagerSheet.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/CustomTileProviderManagerSheet.kt
new file mode 100644
index 000000000..0787d936a
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/CustomTileProviderManagerSheet.kt
@@ -0,0 +1,258 @@
+/*
+ * 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.ui.map.components
+
+import android.widget.Toast
+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.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.Edit
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+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.material3.TextButton
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.geeksville.mesh.R
+import com.geeksville.mesh.ui.map.CustomTileProviderConfig
+import com.geeksville.mesh.ui.map.MapViewModel
+import kotlinx.coroutines.flow.collectLatest
+
+@Suppress("LongMethod")
+@Composable
+fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
+ val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
+ var editingConfig by remember { mutableStateOf(null) }
+ var showEditDialog by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ mapViewModel.errorFlow.collectLatest { errorMessage ->
+ Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ if (showEditDialog) {
+ AddEditCustomTileProviderDialog(
+ config = editingConfig,
+ onDismiss = { showEditDialog = false },
+ onSave = { name, url ->
+ if (editingConfig == null) { // Adding new
+ mapViewModel.addCustomTileProvider(name, url)
+ } else { // Editing existing
+ mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url))
+ }
+ showEditDialog = false
+ },
+ mapViewModel = mapViewModel,
+ )
+ }
+
+ LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
+ item {
+ Text(
+ text = stringResource(R.string.manage_custom_tile_sources),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(16.dp),
+ )
+ HorizontalDivider()
+ }
+
+ if (customTileProviders.isEmpty()) {
+ item {
+ Text(
+ text = stringResource(R.string.no_custom_tile_sources_found),
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ } else {
+ items(customTileProviders, key = { it.id }) { config ->
+ ListItem(
+ headlineContent = { Text(config.name) },
+ supportingContent = { Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) },
+ trailingContent = {
+ Row {
+ IconButton(
+ onClick = {
+ editingConfig = config
+ showEditDialog = true
+ },
+ ) {
+ Icon(
+ Icons.Filled.Edit,
+ contentDescription = stringResource(R.string.edit_custom_tile_source),
+ )
+ }
+ IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
+ Icon(
+ Icons.Filled.Delete,
+ contentDescription = stringResource(R.string.delete_custom_tile_source),
+ )
+ }
+ }
+ },
+ )
+ HorizontalDivider()
+ }
+ }
+
+ item {
+ Button(
+ onClick = {
+ editingConfig = null
+ showEditDialog = true
+ },
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ ) {
+ Text(stringResource(R.string.add_custom_tile_source))
+ }
+ }
+ }
+}
+
+@Suppress("LongMethod")
+@Composable
+private fun AddEditCustomTileProviderDialog(
+ config: CustomTileProviderConfig?,
+ onDismiss: () -> Unit,
+ onSave: (String, String) -> Unit,
+ mapViewModel: MapViewModel,
+) {
+ var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
+ var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
+ var nameError by remember { mutableStateOf(null) }
+ var urlError by remember { mutableStateOf(null) }
+ val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
+
+ val emptyNameError = stringResource(R.string.name_cannot_be_empty)
+ val providerNameExistsError = stringResource(R.string.provider_name_exists)
+ val urlCannotBeEmptyError = stringResource(R.string.url_cannot_be_empty)
+ val urlMustContainPlaceholdersError = stringResource(R.string.url_must_contain_placeholders)
+
+ fun validateAndSave() {
+ val currentNameError =
+ validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError)
+ val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
+
+ nameError = currentNameError
+ urlError = currentUrlError
+
+ if (currentNameError == null && currentUrlError == null) {
+ onSave(name, url)
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ if (config == null) {
+ stringResource(R.string.add_custom_tile_source)
+ } else {
+ stringResource(R.string.edit_custom_tile_source)
+ },
+ )
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ nameError = null
+ },
+ label = { Text(stringResource(R.string.name)) },
+ isError = nameError != null,
+ supportingText = { nameError?.let { Text(it) } },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = url,
+ onValueChange = {
+ url = it
+ urlError = null
+ },
+ label = { Text(stringResource(R.string.url_template)) },
+ isError = urlError != null,
+ supportingText = {
+ if (urlError != null) {
+ Text(urlError!!)
+ } else {
+ Text(stringResource(R.string.url_template_hint))
+ }
+ },
+ singleLine = false,
+ maxLines = 2,
+ )
+ }
+ },
+ confirmButton = { Button(onClick = { validateAndSave() }) { Text(stringResource(R.string.save)) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
+ )
+}
+
+private fun validateName(
+ name: String,
+ providers: List,
+ currentId: String?,
+ emptyNameError: String,
+ nameExistsError: String,
+): String? = if (name.isBlank()) {
+ emptyNameError
+} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
+ nameExistsError
+} else {
+ null
+}
+
+private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
+ if (url.isBlank()) {
+ emptyUrlError
+ } else if (
+ !url.contains("{z}", ignoreCase = true) ||
+ !url.contains("{x}", ignoreCase = true) ||
+ !url.contains("{y}", ignoreCase = true)
+ ) {
+ mustContainPlaceholdersError
+ } else {
+ null
+ }
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
new file mode 100644
index 000000000..368c1b5dc
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt
@@ -0,0 +1,338 @@
+/*
+ * 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.ui.map.components
+
+import android.app.DatePickerDialog
+import android.app.TimePickerDialog
+import android.widget.DatePicker
+import android.widget.TimePicker
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.geeksville.mesh.MeshProtos.Waypoint
+import com.geeksville.mesh.R
+import com.geeksville.mesh.copy
+import com.geeksville.mesh.ui.common.components.EmojiPickerDialog
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+import java.util.TimeZone
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
+@Composable
+fun EditWaypointDialog(
+ waypoint: Waypoint,
+ onSendClicked: (Waypoint) -> Unit,
+ onDeleteClicked: (Waypoint) -> Unit,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var waypointInput by remember { mutableStateOf(waypoint) }
+ val title = if (waypoint.id == 0) R.string.waypoint_new else R.string.waypoint_edit
+ val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
+ val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon
+ var showEmojiPickerView by remember { mutableStateOf(false) }
+
+ val context = LocalContext.current
+ val calendar = remember { Calendar.getInstance() }
+
+ // Initialize date and time states from waypointInput.expire
+ var selectedDateString by remember { mutableStateOf("") }
+ var selectedTimeString by remember { mutableStateOf("") }
+ var isExpiryEnabled by remember {
+ mutableStateOf(waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE)
+ }
+
+ val locale = Locale.getDefault()
+ val dateFormat = remember {
+ if (locale.country.equals("US", ignoreCase = true)) {
+ SimpleDateFormat("MM/dd/yyyy", locale)
+ } else {
+ SimpleDateFormat("dd/MM/yyyy", locale)
+ }
+ }
+ val timeFormat = remember {
+ val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
+ if (is24Hour) {
+ SimpleDateFormat("HH:mm", locale)
+ } else {
+ SimpleDateFormat("hh:mm a", locale)
+ }
+ }
+ dateFormat.timeZone = TimeZone.getDefault()
+ timeFormat.timeZone = TimeZone.getDefault()
+
+ LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
+ if (isExpiryEnabled) {
+ if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
+ calendar.timeInMillis = waypointInput.expire * 1000L
+ selectedDateString = dateFormat.format(calendar.time)
+ selectedTimeString = timeFormat.format(calendar.time)
+ } else { // If enabled but not set, default to 8 hours from now
+ calendar.timeInMillis = System.currentTimeMillis()
+ calendar.add(Calendar.HOUR_OF_DAY, 8)
+ waypointInput = waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
+ }
+ } else {
+ selectedDateString = ""
+ selectedTimeString = ""
+ }
+ }
+
+ if (!showEmojiPickerView) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ title = {
+ Text(
+ text = stringResource(title),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ },
+ text = {
+ Column(modifier = modifier.fillMaxWidth()) {
+ OutlinedTextField(
+ value = waypointInput.name,
+ onValueChange = { waypointInput = waypointInput.copy { name = it.take(29) } },
+ label = { Text(stringResource(R.string.name)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
+ modifier = Modifier.fillMaxWidth(),
+ trailingIcon = {
+ IconButton(onClick = { showEmojiPickerView = true }) {
+ Text(
+ text = String(Character.toChars(currentEmojiCodepoint)),
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
+ .padding(6.dp),
+ fontSize = 20.sp,
+ )
+ }
+ },
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ OutlinedTextField(
+ value = waypointInput.description,
+ onValueChange = { waypointInput = waypointInput.copy { description = it.take(99) } },
+ label = { Text(stringResource(R.string.description)) },
+ keyboardOptions =
+ KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { /* Handle next/done focus */ }),
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 2,
+ maxLines = 3,
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ imageVector = Icons.Default.Lock,
+ contentDescription = stringResource(R.string.locked),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.locked))
+ }
+ Switch(
+ checked = waypointInput.lockedTo != 0,
+ onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } },
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ imageVector = Icons.Default.CalendarMonth,
+ contentDescription = stringResource(R.string.expires),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.expires))
+ }
+ Switch(
+ checked = isExpiryEnabled,
+ onCheckedChange = { checked ->
+ isExpiryEnabled = checked
+ if (checked) {
+ // Default to 8 hours from now if not already set
+ if (waypointInput.expire == 0 || waypointInput.expire == Int.MAX_VALUE) {
+ val cal = Calendar.getInstance()
+ cal.timeInMillis = System.currentTimeMillis()
+ cal.add(Calendar.HOUR_OF_DAY, 8)
+ waypointInput =
+ waypointInput.copy { expire = (cal.timeInMillis / 1000).toInt() }
+ }
+ // LaunchedEffect will update date/time strings
+ } else {
+ waypointInput = waypointInput.copy { expire = Int.MAX_VALUE }
+ }
+ },
+ )
+ }
+
+ if (isExpiryEnabled) {
+ val currentCalendar =
+ Calendar.getInstance().apply {
+ if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
+ timeInMillis = waypointInput.expire * 1000L
+ } else {
+ timeInMillis = System.currentTimeMillis()
+ add(Calendar.HOUR_OF_DAY, 8) // Default if re-enabling
+ }
+ }
+ val year = currentCalendar.get(Calendar.YEAR)
+ val month = currentCalendar.get(Calendar.MONTH)
+ val day = currentCalendar.get(Calendar.DAY_OF_MONTH)
+ val hour = currentCalendar.get(Calendar.HOUR_OF_DAY)
+ val minute = currentCalendar.get(Calendar.MINUTE)
+
+ val datePickerDialog =
+ DatePickerDialog(
+ context,
+ { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
+ calendar.clear()
+ calendar.set(selectedYear, selectedMonth, selectedDay, hour, minute)
+ waypointInput =
+ waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
+ },
+ year,
+ month,
+ day,
+ )
+
+ val timePickerDialog =
+ TimePickerDialog(
+ context,
+ { _: TimePicker, selectedHour: Int, selectedMinute: Int ->
+ // Keep the existing date part
+ val tempCal = Calendar.getInstance()
+ tempCal.timeInMillis = waypointInput.expire * 1000L
+ tempCal.set(Calendar.HOUR_OF_DAY, selectedHour)
+ tempCal.set(Calendar.MINUTE, selectedMinute)
+ waypointInput =
+ waypointInput.copy { expire = (tempCal.timeInMillis / 1000).toInt() }
+ },
+ hour,
+ minute,
+ android.text.format.DateFormat.is24HourFormat(context),
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = { datePickerDialog.show() }) { Text(stringResource(R.string.date)) }
+ Text(
+ modifier = Modifier.padding(top = 4.dp),
+ text = selectedDateString,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = { timePickerDialog.show() }) { Text(stringResource(R.string.time)) }
+ Text(
+ modifier = Modifier.padding(top = 4.dp),
+ text = selectedTimeString,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ if (waypoint.id != 0) {
+ TextButton(
+ onClick = { onDeleteClicked(waypointInput) },
+ modifier = Modifier.padding(end = 8.dp),
+ ) {
+ Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
+ TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
+ Text(stringResource(R.string.cancel))
+ }
+ Button(onClick = { onSendClicked(waypointInput) }, enabled = waypointInput.name.isNotBlank()) {
+ Text(stringResource(R.string.send))
+ }
+ }
+ },
+ dismissButton = null, // Using custom buttons in confirmButton Row
+ modifier = modifier,
+ )
+ } else {
+ EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji ->
+ showEmojiPickerView = false
+ waypointInput = waypointInput.copy { icon = selectedEmoji.codePointAt(0) }
+ }
+ }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapButton.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapButton.kt
new file mode 100644
index 000000000..a35ff9b7d
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapButton.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.ui.map.components
+
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+
+@Composable
+fun MapButton(icon: ImageVector, contentDescription: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
+ FilledIconButton(onClick = onClick, modifier = modifier) {
+ Icon(imageVector = icon, contentDescription = contentDescription)
+ }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt
new file mode 100644
index 000000000..1aa939342
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.ui.map.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Layers
+import androidx.compose.material.icons.outlined.Map
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingToolbarScrollBehavior
+import androidx.compose.material3.VerticalFloatingToolbar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.geeksville.mesh.R
+import com.geeksville.mesh.ui.map.MapViewModel
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MapControlsOverlay(
+ modifier: Modifier = Modifier,
+ mapFilterMenuExpanded: Boolean,
+ onMapFilterMenuDismissRequest: () -> Unit,
+ onToggleMapFilterMenu: () -> Unit,
+ mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
+ mapTypeMenuExpanded: Boolean,
+ onMapTypeMenuDismissRequest: () -> Unit,
+ onToggleMapTypeMenu: () -> Unit,
+ onManageLayersClicked: () -> Unit,
+ onManageCustomTileProvidersClicked: () -> Unit, // New parameter
+ showFilterButton: Boolean,
+ scrollBehavior: FloatingToolbarScrollBehavior,
+) {
+ VerticalFloatingToolbar(
+ modifier = modifier,
+ expanded = true,
+ leadingContent = {},
+ trailingContent = {},
+ scrollBehavior = scrollBehavior,
+ content = {
+ if (showFilterButton) {
+ Box {
+ MapButton(
+ icon = Icons.Outlined.Tune,
+ contentDescription = stringResource(id = R.string.map_filter),
+ onClick = onToggleMapFilterMenu,
+ )
+ MapFilterDropdown(
+ expanded = mapFilterMenuExpanded,
+ onDismissRequest = onMapFilterMenuDismissRequest,
+ mapViewModel = mapViewModel,
+ )
+ }
+ }
+
+ Box {
+ MapButton(
+ icon = Icons.Outlined.Map,
+ contentDescription = stringResource(id = R.string.map_tile_source),
+ onClick = onToggleMapTypeMenu,
+ )
+ MapTypeDropdown(
+ expanded = mapTypeMenuExpanded,
+ onDismissRequest = onMapTypeMenuDismissRequest,
+ mapViewModel = mapViewModel, // Pass mapViewModel
+ onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
+ )
+ }
+
+ MapButton(
+ icon = Icons.Outlined.Layers,
+ contentDescription = stringResource(id = R.string.manage_map_layers),
+ onClick = onManageLayersClicked,
+ )
+ },
+ )
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt
new file mode 100644
index 000000000..d1b2b4bfe
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.ui.map.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Place
+import androidx.compose.material.icons.outlined.RadioButtonUnchecked
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.geeksville.mesh.R
+import com.geeksville.mesh.ui.map.MapViewModel
+
+@Composable
+internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
+ val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.only_favorites)) },
+ onClick = { mapViewModel.toggleOnlyFavorites() },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Filled.Favorite,
+ contentDescription = stringResource(id = R.string.only_favorites),
+ )
+ },
+ trailingIcon = {
+ Checkbox(
+ checked = mapFilterState.onlyFavorites,
+ onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
+ )
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.show_waypoints)) },
+ onClick = { mapViewModel.toggleShowWaypointsOnMap() },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Filled.Place,
+ contentDescription = stringResource(id = R.string.show_waypoints),
+ )
+ },
+ trailingIcon = {
+ Checkbox(
+ checked = mapFilterState.showWaypoints,
+ onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
+ )
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.show_precision_circle)) },
+ onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon
+ contentDescription = stringResource(id = R.string.show_precision_circle),
+ )
+ },
+ trailingIcon = {
+ Checkbox(
+ checked = mapFilterState.showPrecisionCircle,
+ onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
+ )
+ },
+ )
+ }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapTypeDropdown.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapTypeDropdown.kt
new file mode 100644
index 000000000..e34521936
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapTypeDropdown.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.ui.map.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.geeksville.mesh.R
+import com.geeksville.mesh.ui.map.MapViewModel
+import com.google.maps.android.compose.MapType
+
+@Suppress("LongMethod")
+@Composable
+internal fun MapTypeDropdown(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ mapViewModel: MapViewModel,
+ onManageCustomTileProvidersClicked: () -> Unit,
+) {
+ val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
+ val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
+ val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
+
+ val googleMapTypes =
+ listOf(
+ stringResource(id = R.string.map_type_normal) to MapType.NORMAL,
+ stringResource(id = R.string.map_type_satellite) to MapType.SATELLITE,
+ stringResource(id = R.string.map_type_terrain) to MapType.TERRAIN,
+ stringResource(id = R.string.map_type_hybrid) to MapType.HYBRID,
+ )
+
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+ googleMapTypes.forEach { (name, type) ->
+ DropdownMenuItem(
+ text = { Text(name) },
+ onClick = {
+ mapViewModel.setSelectedGoogleMapType(type)
+ onDismissRequest() // Close menu
+ },
+ trailingIcon =
+ if (selectedCustomUrl == null && selectedGoogleMapType == type) {
+ { Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.selected_map_type)) }
+ } else {
+ null
+ },
+ )
+ }
+
+ if (customTileProviders.isNotEmpty()) {
+ HorizontalDivider()
+ customTileProviders.forEach { config ->
+ DropdownMenuItem(
+ text = { Text(config.name) },
+ onClick = {
+ mapViewModel.selectCustomTileProvider(config)
+ onDismissRequest() // Close menu
+ },
+ trailingIcon =
+ if (selectedCustomUrl == config.urlTemplate) {
+ {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = stringResource(R.string.selected_map_type),
+ )
+ }
+ } else {
+ null
+ },
+ )
+ }
+ }
+ HorizontalDivider()
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.manage_custom_tile_sources)) },
+ onClick = {
+ onManageCustomTileProvidersClicked()
+ onDismissRequest()
+ },
+ )
+ }
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/NodeClusterMarkers.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/NodeClusterMarkers.kt
new file mode 100644
index 000000000..e72137721
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/NodeClusterMarkers.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.ui.map.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.ui.graphics.Color
+import com.geeksville.mesh.ui.map.BaseMapViewModel
+import com.geeksville.mesh.ui.map.NodeClusterItem
+import com.geeksville.mesh.ui.node.components.NodeChip
+import com.google.maps.android.clustering.Cluster
+import com.google.maps.android.compose.Circle
+import com.google.maps.android.compose.MapsComposeExperimentalApi
+import com.google.maps.android.compose.clustering.Clustering
+
+@OptIn(MapsComposeExperimentalApi::class)
+@Suppress("NestedBlockDepth")
+@Composable
+fun NodeClusterMarkers(
+ nodeClusterItems: List,
+ mapFilterState: BaseMapViewModel.MapFilterState,
+ navigateToNodeDetails: (Int) -> Unit,
+ onClusterClick: (Cluster) -> Boolean,
+) {
+ if (mapFilterState.showPrecisionCircle) {
+ nodeClusterItems.forEach { clusterItem ->
+ key(clusterItem.node.num) {
+ // Add a stable key for each circle
+ clusterItem.getPrecisionMeters()?.let { precisionMeters ->
+ if (precisionMeters > 0) {
+ Circle(
+ center = clusterItem.position,
+ radius = precisionMeters,
+ fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
+ strokeColor = Color(clusterItem.node.colors.second),
+ strokeWidth = 2f,
+ zIndex = 1f, // Ensure circles are drawn above markers
+ )
+ }
+ }
+ }
+ }
+ }
+ Clustering(
+ items = nodeClusterItems,
+ onClusterClick = onClusterClick,
+ onClusterItemInfoWindowClick = { item ->
+ navigateToNodeDetails(item.node.num)
+ false
+ },
+ clusterItemContent = { clusterItem ->
+ NodeChip(node = clusterItem.node, enabled = false, isThisNode = false, isConnected = false) {}
+ },
+ )
+}
diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/WaypointMarkers.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/WaypointMarkers.kt
new file mode 100644
index 000000000..756e0880e
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/WaypointMarkers.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.ui.map.components
+
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import com.geeksville.mesh.MeshProtos
+import com.geeksville.mesh.R
+import com.geeksville.mesh.ui.map.BaseMapViewModel
+import com.geeksville.mesh.ui.node.DEG_D
+import com.google.android.gms.maps.model.BitmapDescriptor
+import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.compose.Marker
+import com.google.maps.android.compose.rememberUpdatedMarkerState
+
+@Composable
+fun WaypointMarkers(
+ displayableWaypoints: List,
+ mapFilterState: BaseMapViewModel.MapFilterState,
+ myNodeNum: Int,
+ isConnected: Boolean,
+ unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
+ onEditWaypointRequest: (MeshProtos.Waypoint) -> Unit,
+) {
+ val context = LocalContext.current
+ if (mapFilterState.showWaypoints) {
+ displayableWaypoints.forEach { waypoint ->
+ val markerState =
+ rememberUpdatedMarkerState(position = LatLng(waypoint.latitudeI * DEG_D, waypoint.longitudeI * DEG_D))
+
+ Marker(
+ state = markerState,
+ icon =
+ if (waypoint.icon == 0) {
+ unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin)
+ } else {
+ unicodeEmojiToBitmapProvider(waypoint.icon)
+ },
+ title = waypoint.name,
+ snippet = waypoint.description,
+ visible = true,
+ onInfoWindowClick = {
+ if (waypoint.lockedTo == 0 || waypoint.lockedTo == myNodeNum || !isConnected) {
+ onEditWaypointRequest(waypoint)
+ } else {
+ Toast.makeText(context, context.getString(R.string.locked), Toast.LENGTH_SHORT).show()
+ }
+ },
+ )
+ }
+ }
+}
+
+private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin
diff --git a/app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt b/app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt
new file mode 100644
index 000000000..cf4fadfbb
--- /dev/null
+++ b/app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.ui.node
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.geeksville.mesh.model.MetricsViewModel
+import com.geeksville.mesh.model.UIViewModel
+import com.geeksville.mesh.ui.map.MapView
+
+const val DEG_D = 1e-7
+
+@Composable
+fun NodeMapScreen(uiViewModel: UIViewModel, metricsViewModel: MetricsViewModel = hiltViewModel()) {
+ val state by metricsViewModel.state.collectAsState()
+ val positions = state.positionLogs
+ val destNum = state.node?.num
+ MapView(uiViewModel = uiViewModel, focusedNodeNum = destNum, nodeTrack = positions, navigateToNodeDetails = {})
+}
diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt
index 9e1e9fccb..2aa6f9afa 100644
--- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt
+++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt
@@ -19,7 +19,6 @@ package com.geeksville.mesh
import android.graphics.Color
import android.os.Parcelable
-import com.geeksville.mesh.util.GPSFormat
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.bearing
import com.geeksville.mesh.util.latLongToMeter
@@ -115,14 +114,6 @@ data class Position(
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
- fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
- ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.DEC(this)
- ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.DMS(this)
- ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.UTM(this)
- ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.MGRS(this)
- else -> GPSFormat.DEC(this)
- }
-
override fun toString(): String =
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
}
diff --git a/app/src/main/java/com/geeksville/mesh/model/Node.kt b/app/src/main/java/com/geeksville/mesh/model/Node.kt
index a6f33a6bb..aa86651a6 100644
--- a/app/src/main/java/com/geeksville/mesh/model/Node.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/Node.kt
@@ -62,9 +62,14 @@ data class Node(
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
}
- val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET
- val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty()
- val mismatchKey get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
+ val isUnknownUser
+ get() = user.hwModel == MeshProtos.HardwareModel.UNSET
+
+ val hasPKC
+ get() = (publicKey ?: user.publicKey).isNotEmpty()
+
+ val mismatchKey
+ get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
@@ -72,20 +77,28 @@ data class Node(
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics.getDefaultInstance()
- val batteryLevel get() = deviceMetrics.batteryLevel
- val voltage get() = deviceMetrics.voltage
- val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
+ val batteryLevel
+ get() = deviceMetrics.batteryLevel
- val latitude get() = position.latitudeI * 1e-7
- val longitude get() = position.longitudeI * 1e-7
+ val voltage
+ get() = deviceMetrics.voltage
- private fun hasValidPosition(): Boolean {
- return latitude != 0.0 && longitude != 0.0 &&
- (latitude >= -90 && latitude <= 90.0) &&
- (longitude >= -180 && longitude <= 180)
- }
+ val batteryStr
+ get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
- val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() }
+ val latitude
+ get() = position.latitudeI * 1e-7
+
+ val longitude
+ get() = position.longitudeI * 1e-7
+
+ private fun hasValidPosition(): Boolean = latitude != 0.0 &&
+ longitude != 0.0 &&
+ (latitude >= -90 && latitude <= 90.0) &&
+ (longitude >= -180 && longitude <= 180)
+
+ val validPosition: MeshProtos.Position?
+ get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
fun distance(o: Node): Int? = when {
@@ -103,70 +116,58 @@ data class Node(
else -> com.geeksville.mesh.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
}
- fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
- DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
- DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
- DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
- DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
- else -> GPSFormat.toDEC(latitude, longitude)
- }
+ fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String {
- val temp = if (temperature != 0f) {
- if (isFahrenheit) {
- "%.1f°F".format(celsiusToFahrenheit(temperature))
+ val temp =
+ if (temperature != 0f) {
+ if (isFahrenheit) {
+ "%.1f°F".format(celsiusToFahrenheit(temperature))
+ } else {
+ "%.1f°C".format(temperature)
+ }
} else {
- "%.1f°C".format(temperature)
+ null
}
- } else {
- null
- }
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
- val soilTemperatureStr = if (soilTemperature != 0f) {
- if (isFahrenheit) {
- "%.1f°F".format(celsiusToFahrenheit(soilTemperature))
+ val soilTemperatureStr =
+ if (soilTemperature != 0f) {
+ if (isFahrenheit) {
+ "%.1f°F".format(celsiusToFahrenheit(soilTemperature))
+ } else {
+ "%.1f°C".format(soilTemperature)
+ }
} else {
- "%.1f°C".format(soilTemperature)
+ null
}
- } else {
- null
- }
val soilMoistureRange = 0..100
val soilMoisture =
if (soilMoisture in soilMoistureRange && soilTemperature != 0f) {
"%d%%".format(soilMoisture)
- } else { null }
+ } else {
+ null
+ }
val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null
val current = if (current != 0f) "%.1fmA".format(current) else null
val iaq = if (iaq != 0) "IAQ: $iaq" else null
- return listOfNotNull(
- temp,
- humidity,
- soilTemperatureStr,
- soilMoisture,
- voltage,
- current,
- iaq,
- ).joinToString(" ")
+ return listOfNotNull(temp, humidity, soilTemperatureStr, soilMoisture, voltage, current, iaq).joinToString(" ")
}
private fun PaxcountProtos.Paxcount.getDisplayString() =
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
- fun getTelemetryString(isFahrenheit: Boolean = false): String {
- return listOfNotNull(
- paxcounter.getDisplayString(),
- environmentMetrics.getDisplayString(isFahrenheit),
- ).joinToString(" ")
- }
+ fun getTelemetryString(isFahrenheit: Boolean = false): String =
+ listOfNotNull(paxcounter.getDisplayString(), environmentMetrics.getDisplayString(isFahrenheit))
+ .joinToString(" ")
}
-fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in listOf(
- ConfigProtos.Config.DeviceConfig.Role.REPEATER,
- ConfigProtos.Config.DeviceConfig.Role.ROUTER,
- ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
- ConfigProtos.Config.DeviceConfig.Role.SENSOR,
- ConfigProtos.Config.DeviceConfig.Role.TRACKER,
- ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
-)
+fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
+ listOf(
+ ConfigProtos.Config.DeviceConfig.Role.REPEATER,
+ ConfigProtos.Config.DeviceConfig.Role.ROUTER,
+ ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
+ ConfigProtos.Config.DeviceConfig.Role.SENSOR,
+ ConfigProtos.Config.DeviceConfig.Role.TRACKER,
+ ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
+ )
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index 60ca0f596..9b022a9ca 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -64,10 +64,10 @@ import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceAction
-import com.geeksville.mesh.ui.map.MAP_STYLE_ID
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.util.getShortDate
import com.geeksville.mesh.util.positionToMeter
+import com.geeksville.mesh.util.toggleBooleanPreference
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -82,7 +82,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
@@ -102,27 +101,27 @@ import kotlin.math.roundToInt
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
// name and if the result is 3 or more characters, use the first three characters. If not, just take
// the first 3 characters of the original name.
-fun getInitials(nameIn: String): String {
- val nchars = 4
- val minchars = 2
- val name = nameIn.trim().withoutEmojis()
+fun getInitials(fullName: String): String {
+ val maxInitialLength = 4
+ val minWordCountForInitials = 2
+ val name = fullName.trim().withoutEmojis()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials =
when (words.size) {
- in 0 until minchars -> {
- val nm =
+ in 0 until minWordCountForInitials -> {
+ val nameWithoutVowels =
if (name.isNotEmpty()) {
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
} else {
""
}
- if (nm.length >= nchars) nm else name
+ if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
}
else -> words.map { it.first() }.joinToString("")
}
- return initials.take(nchars)
+ return initials.take(maxInitialLength)
}
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
@@ -161,7 +160,6 @@ data class NodesUiState(
val includeUnknown: Boolean = false,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
- val gpsFormat: Int = 0,
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
val showDetails: Boolean = false,
@@ -185,7 +183,7 @@ data class Contact(
val nodeColors: Pair? = null,
)
-@Suppress("LongParameterList", "LargeClass")
+@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
@HiltViewModel
class UIViewModel
@Inject
@@ -193,7 +191,7 @@ constructor(
private val app: Application,
private val nodeDB: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
- private val radioInterfaceService: RadioInterfaceService,
+ radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: MeshLogRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val packetRepository: PacketRepository,
@@ -301,9 +299,6 @@ constructor(
val meshService: IMeshService?
get() = radioConfigRepository.meshService
- val selectedBluetooth
- get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x'
-
private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance())
val localConfig: StateFlow = _localConfig
val config
@@ -338,11 +333,6 @@ constructor(
private val onlyOnline = MutableStateFlow(preferences.getBoolean("only-online", false))
private val onlyDirect = MutableStateFlow(preferences.getBoolean("only-direct", false))
- private val onlyFavorites = MutableStateFlow(preferences.getBoolean("only-favorites", false))
- private val showWaypointsOnMap = MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true))
- private val showPrecisionCircleOnMap =
- MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true))
-
private val _showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false))
val showIgnored: StateFlow = _showIgnored
@@ -351,6 +341,7 @@ constructor(
private val _hasShownNotPairedWarning =
MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false))
+
val hasShownNotPairedWarning: StateFlow = _hasShownNotPairedWarning.asStateFlow()
fun suppressNoPairedWarning() {
@@ -358,40 +349,22 @@ constructor(
preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) }
}
- private fun toggleBooleanPreference(
- state: MutableStateFlow,
- key: String,
- onChanged: (Boolean) -> Unit = {},
- ) {
- val newValue = !state.value
- state.value = newValue
- preferences.edit { putBoolean(key, newValue) }
- onChanged(newValue)
- }
+ fun toggleShowIgnored() = preferences.toggleBooleanPreference(_showIgnored, "show-ignored")
- fun toggleShowIgnored() = toggleBooleanPreference(_showIgnored, "show-ignored")
-
- fun toggleShowQuickChat() = toggleBooleanPreference(_showQuickChat, "show-quick-chat")
+ fun toggleShowQuickChat() = preferences.toggleBooleanPreference(_showQuickChat, "show-quick-chat")
fun setSortOption(sort: NodeSortOption) {
nodeSortOption.value = sort
preferences.edit { putInt("node-sort-option", sort.ordinal) }
}
- fun toggleShowDetails() = toggleBooleanPreference(showDetails, "show-details")
+ fun toggleShowDetails() = preferences.toggleBooleanPreference(showDetails, "show-details")
- fun toggleIncludeUnknown() = toggleBooleanPreference(includeUnknown, "include-unknown")
+ fun toggleIncludeUnknown() = preferences.toggleBooleanPreference(includeUnknown, "include-unknown")
- fun toggleOnlyOnline() = toggleBooleanPreference(onlyOnline, "only-online")
+ fun toggleOnlyOnline() = preferences.toggleBooleanPreference(onlyOnline, "only-online")
- fun toggleOnlyDirect() = toggleBooleanPreference(onlyDirect, "only-direct")
-
- fun toggleOnlyFavorites() = toggleBooleanPreference(onlyFavorites, "only-favorites")
-
- fun toggleShowWaypointsOnMap() = toggleBooleanPreference(showWaypointsOnMap, "show-waypoints-on-map")
-
- fun toggleShowPrecisionCircleOnMap() =
- toggleBooleanPreference(showPrecisionCircleOnMap, "show-precision-circle-on-map")
+ fun toggleOnlyDirect() = preferences.toggleBooleanPreference(onlyDirect, "only-direct")
data class NodeFilterState(
val filterText: String,
@@ -425,7 +398,6 @@ constructor(
includeUnknown = filterFlow.includeUnknown,
onlyOnline = filterFlow.onlyOnline,
onlyDirect = filterFlow.onlyDirect,
- gpsFormat = profile.config.display.gpsFormat.number,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
showDetails = showDetails,
@@ -474,22 +446,6 @@ constructor(
initialValue = 0,
)
- data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean)
-
- val mapFilterStateFlow: StateFlow =
- combine(onlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) {
- favoritesOnly,
- showWaypoints,
- showPrecisionCircle,
- ->
- MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle)
- }
- .stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = MapFilterState(false, true, true),
- )
-
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow
get() = nodeDB.myNodeInfo
@@ -497,22 +453,15 @@ constructor(
val ourNodeInfo: StateFlow
get() = nodeDB.ourNodeInfo
- val nodesWithPosition
- get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }
-
- var mapStyleId: Int
- get() = preferences.getInt(MAP_STYLE_ID, 0)
- set(value) = preferences.edit { putInt(MAP_STYLE_ID, value) }
-
fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST)
- val snackbarState = SnackbarHostState()
+ private val snackBarHostState = SnackbarHostState()
- fun showSnackbar(text: Int) = showSnackbar(app.getString(text))
+ fun showSnackBar(text: Int) = showSnackBar(app.getString(text))
- fun showSnackbar(text: String) = viewModelScope.launch { snackbarState.showSnackbar(text) }
+ fun showSnackBar(text: String) = viewModelScope.launch { snackBarHostState.showSnackbar(text) }
init {
radioConfigRepository.errorMessage
@@ -615,15 +564,6 @@ constructor(
initialValue = emptyList(),
)
- val waypoints =
- packetRepository.getWaypoints().mapLatest { list ->
- list
- .associateBy { packet -> packet.data.waypoint!!.id }
- .filterValues {
- it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000
- }
- }
-
fun generatePacketId(): Int? {
return try {
meshService?.packetId
@@ -749,8 +689,6 @@ constructor(
val connectionState
get() = radioConfigRepository.connectionState
- fun isConnected() = isConnectedStateFlow.value
-
val isConnectedStateFlow =
radioConfigRepository.connectionState
.map { it.isConnected() }
@@ -763,7 +701,7 @@ constructor(
fun requestChannelUrl(url: Uri) = runCatching { _requestChannelSet.value = url.toChannelSet() }
.onFailure { ex ->
errormsg("Channel url error: ${ex.message}")
- showSnackbar(R.string.channel_invalid)
+ showSnackBar(R.string.channel_invalid)
}
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
@@ -921,6 +859,7 @@ constructor(
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf()
+
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
@@ -939,7 +878,9 @@ constructor(
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
- nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
+ nodePositions[
+ proto.from.takeIf { it != 0 } ?: myNodeNum,
+ ] = position
}
}
@@ -972,9 +913,9 @@ constructor(
""
} else {
positionToMeter(
- rxPosition!!, // Use rxPosition but only if rxPos was
+ Position(rxPosition!!), // Use rxPosition but only if rxPos was
// valid
- senderPosition!!, // Use senderPosition but only if
+ Position(senderPosition!!), // Use senderPosition but only if
// senderPos was valid
)
.roundToInt()
@@ -998,7 +939,8 @@ constructor(
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
- // elevation,rx snr,distance,hop limit,payload
+ // elevation,rx
+ // snr,distance,hop limit,payload
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
diff --git a/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt b/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt
deleted file mode 100644
index 2115d1342..000000000
--- a/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt
+++ /dev/null
@@ -1,212 +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.model.map
-
-import org.osmdroid.tileprovider.tilesource.ITileSource
-import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
-import org.osmdroid.tileprovider.tilesource.TileSourceFactory
-import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
-import org.osmdroid.util.MapTileIndex
-
-
-class CustomTileSource {
-
- companion object {
- val OPENWEATHER_RADAR = OnlineTileSourceAuth(
- "Open Weather Map", 1, 22, 256, ".png", arrayOf(
- "https://tile.openweathermap.org/map/"
- ), "Openweathermap",
- TileSourcePolicy(
- 4,
- TileSourcePolicy.FLAG_NO_BULK
- or TileSourcePolicy.FLAG_NO_PREVENTIVE
- or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
- or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
- ),
- "precipitation",
- ""
- )
- //
-// val RAIN_VIEWER = object : OnlineTileSourceBase(
-// "RainViewer", 1, 15, 256, ".png", arrayOf(
-// "https://tilecache.rainviewer.com/v2/coverage/"
-// ), "RainViewer",
-// TileSourcePolicy(
-// 4,
-// TileSourcePolicy.FLAG_NO_BULK
-// or TileSourcePolicy.FLAG_NO_PREVENTIVE
-// or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
-// or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
-// )
-// ) {
-// override fun getTileURLString(pMapTileIndex: Long): String {
-// return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
-// .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
-// + "/" + MapTileIndex.getX(pMapTileIndex)
-// + mImageFilenameEnding)
-// }
-// }
-
-
- private val ESRI_IMAGERY = object : OnlineTileSourceBase(
- "ESRI World Overview", 1, 20, 256, ".jpg", arrayOf(
- "https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
- ), "Esri, Maxar, Earthstar Geographics, and the GIS User Community",
- TileSourcePolicy(
- 4,
- TileSourcePolicy.FLAG_NO_BULK
- or TileSourcePolicy.FLAG_NO_PREVENTIVE
- or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
- or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
- )
- ) {
- override fun getTileURLString(pMapTileIndex: Long): String {
- return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
- .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
- + "/" + MapTileIndex.getX(pMapTileIndex)
- + mImageFilenameEnding)
- }
- }
-
- private val ESRI_WORLD_TOPO = object : OnlineTileSourceBase(
- "ESRI World TOPO",
- 1,
- 20,
- 256,
- ".jpg",
- arrayOf(
- "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"
- ),
- "Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
- TileSourcePolicy(
- 4,
- TileSourcePolicy.FLAG_NO_BULK
- or TileSourcePolicy.FLAG_NO_PREVENTIVE
- or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
- or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
- )
- ) {
- override fun getTileURLString(pMapTileIndex: Long): String {
- return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
- .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
- + "/" + MapTileIndex.getX(pMapTileIndex)
- + mImageFilenameEnding)
- }
- }
- private val USGS_HYDRO_CACHE = object : OnlineTileSourceBase(
- "USGS Hydro Cache",
- 0,
- 18,
- 256,
- "",
- arrayOf(
- "https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"
- ),
- "USGS",
- TileSourcePolicy(
- 2,
- TileSourcePolicy.FLAG_NO_PREVENTIVE
- or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
- or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
- )
- ) {
- override fun getTileURLString(pMapTileIndex: Long): String {
- return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
- .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
- + "/" + MapTileIndex.getX(pMapTileIndex)
- + mImageFilenameEnding)
- }
- }
- private val USGS_SHADED_RELIEF = object : OnlineTileSourceBase(
- "USGS Shaded Relief Only",
- 0,
- 18,
- 256,
- "",
- arrayOf(
- "https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/"
- ),
- "USGS",
- TileSourcePolicy(
- 2,
- TileSourcePolicy.FLAG_NO_PREVENTIVE
- or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
- or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
- )
- ) {
- override fun getTileURLString(pMapTileIndex: Long): String {
- return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
- .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
- + "/" + MapTileIndex.getX(pMapTileIndex)
- + mImageFilenameEnding)
- }
- }
-
- /**
- * WMS TILE SERVER
- * More research is required to get this to function correctly with overlays
- */
- val NOAA_RADAR_WMS = NOAAWmsTileSource(
- "Recent Weather Radar",
- arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?"),
- "1",
- "1.1.0",
- "",
- "EPSG%3A3857",
- "",
- "image/png"
- )
-
- /**
- * ===============================================================================================
- */
-
- private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
- private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
- private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
- private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
- private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
- val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
-
- /**
- * Source for each available [ITileSource] and their display names.
- */
- val mTileSources: Map = mapOf(
- MAPNIK to "OpenStreetMap",
- USGS_TOPO to "USGS TOPO",
- OPEN_TOPO to "Open TOPO",
- ESRI_WORLD_TOPO to "ESRI World TOPO",
- USGS_SAT to "USGS Satellite",
- ESRI_IMAGERY to "ESRI World Overview",
- )
-
- fun getTileSource(index: Int): ITileSource {
- return mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE
- }
-
- fun getTileSource(aName: String): ITileSource {
- for (tileSource: ITileSource in mTileSources.keys) {
- if (tileSource.name().equals(aName)) {
- return tileSource
- }
- }
- throw IllegalArgumentException("No such tile source: $aName")
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt
index 3f01b5e8b..48a2044aa 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt
@@ -22,23 +22,19 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.map.MapView
+import com.geeksville.mesh.ui.map.MapViewModel
import kotlinx.serialization.Serializable
sealed class MapRoutes {
- @Serializable
- data object Map : Route
+ @Serializable data object Map : Route
}
-fun NavGraphBuilder.mapGraph(
- navController: NavHostController,
- uiViewModel: UIViewModel,
-) {
+fun NavGraphBuilder.mapGraph(navController: NavHostController, uiViewModel: UIViewModel, mapViewModel: MapViewModel) {
composable {
MapView(
- model = uiViewModel,
- navigateToNodeDetails = {
- navController.navigate(NodesRoutes.NodeDetailGraph(it))
- },
+ uiViewModel = uiViewModel,
+ mapViewModel = mapViewModel,
+ navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt
index c7a1e7d92..543846f4f 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt
@@ -34,6 +34,7 @@ import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
import com.geeksville.mesh.ui.debug.DebugScreen
+import com.geeksville.mesh.ui.map.MapViewModel
import kotlinx.serialization.Serializable
enum class AdminRoute(@StringRes val title: Int) {
@@ -71,6 +72,7 @@ fun NavGraph(
modifier: Modifier = Modifier,
uIViewModel: UIViewModel = hiltViewModel(),
bluetoothViewModel: BluetoothViewModel = hiltViewModel(),
+ mapViewModel: MapViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
) {
val isConnected by uIViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
@@ -86,7 +88,7 @@ fun NavGraph(
) {
contactsGraph(navController, uIViewModel)
nodesGraph(navController, uIViewModel)
- mapGraph(navController, uIViewModel)
+ mapGraph(navController, uIViewModel, mapViewModel)
channelsGraph(navController, uIViewModel)
connectionsGraph(navController, uIViewModel, bluetoothViewModel)
composable { DebugScreen() }
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt
index f1fca4905..61dc747d8 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt
@@ -23,8 +23,8 @@ import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Memory
-import androidx.compose.material.icons.filled.PermScanWifi
import androidx.compose.material.icons.filled.People
+import androidx.compose.material.icons.filled.PermScanWifi
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Router
import androidx.compose.runtime.remember
@@ -39,130 +39,91 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen
import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.metrics.HostMetricsLogScreen
+import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
import com.geeksville.mesh.ui.metrics.PositionLogScreen
import com.geeksville.mesh.ui.metrics.PowerMetricsScreen
import com.geeksville.mesh.ui.metrics.SignalMetricsScreen
import com.geeksville.mesh.ui.metrics.TracerouteLogScreen
-import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
import com.geeksville.mesh.ui.node.NodeDetailScreen
import com.geeksville.mesh.ui.node.NodeMapScreen
import com.geeksville.mesh.ui.node.NodeScreen
import kotlinx.serialization.Serializable
sealed class NodesRoutes {
- @Serializable
- data object Nodes : Route
+ @Serializable data object Nodes : Route
- @Serializable
- data object NodesGraph : Graph
+ @Serializable data object NodesGraph : Graph
- @Serializable
- data class NodeDetailGraph(val destNum: Int? = null) : Graph
+ @Serializable data class NodeDetailGraph(val destNum: Int? = null) : Graph
- @Serializable
- data class NodeDetail(val destNum: Int? = null) : Route
+ @Serializable data class NodeDetail(val destNum: Int? = null) : Route
}
sealed class NodeDetailRoutes {
- @Serializable
- data object DeviceMetrics : Route
+ @Serializable data object DeviceMetrics : Route
- @Serializable
- data object NodeMap : Route
+ @Serializable data object NodeMap : Route
- @Serializable
- data object PositionLog : Route
+ @Serializable data object PositionLog : Route
- @Serializable
- data object EnvironmentMetrics : Route
+ @Serializable data object EnvironmentMetrics : Route
- @Serializable
- data object SignalMetrics : Route
+ @Serializable data object SignalMetrics : Route
- @Serializable
- data object PowerMetrics : Route
+ @Serializable data object PowerMetrics : Route
- @Serializable
- data object TracerouteLog : Route
+ @Serializable data object TracerouteLog : Route
- @Serializable
- data object HostMetricsLog : Route
+ @Serializable data object HostMetricsLog : Route
- @Serializable
- data object PaxMetrics : Route
+ @Serializable data object PaxMetrics : Route
}
-fun NavGraphBuilder.nodesGraph(
- navController: NavHostController,
- uiViewModel: UIViewModel,
-) {
- navigation(
- startDestination = NodesRoutes.Nodes,
- ) {
+fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UIViewModel) {
+ navigation(startDestination = NodesRoutes.Nodes) {
composable {
NodeScreen(
model = uiViewModel,
- navigateToMessages = {
- navController.navigate(ContactsRoutes.Messages(it))
- },
- navigateToNodeDetails = {
- navController.navigate(NodesRoutes.NodeDetailGraph(it))
- },
+ navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
+ navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
}
nodeDetailGraph(navController, uiViewModel)
}
}
-fun NavGraphBuilder.nodeDetailGraph(
- navController: NavHostController,
- uiViewModel: UIViewModel,
-) {
- navigation(
- startDestination = NodesRoutes.NodeDetail(),
- ) {
+fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewModel: UIViewModel) {
+ navigation(startDestination = NodesRoutes.NodeDetail()) {
composable { backStackEntry ->
- val parentEntry = remember(backStackEntry) {
- val parentRoute = backStackEntry.destination.parent!!.route!!
- navController.getBackStackEntry(parentRoute)
- }
+ val parentEntry =
+ remember(backStackEntry) {
+ val parentRoute = backStackEntry.destination.parent!!.route!!
+ navController.getBackStackEntry(parentRoute)
+ }
NodeDetailScreen(
uiViewModel = uiViewModel,
- navigateToMessages = {
- navController.navigate(ContactsRoutes.Messages(it))
- },
- onNavigate = {
- navController.navigate(it)
- },
- onNavigateUp = {
- navController.navigateUp()
- },
+ navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
+ onNavigate = { navController.navigate(it) },
+ onNavigateUp = { navController.navigateUp() },
viewModel = hiltViewModel(parentEntry),
)
}
NodeDetailRoute.entries.forEach { nodeDetailRoute ->
composable(nodeDetailRoute.route::class) { backStackEntry ->
- val parentEntry = remember(backStackEntry) {
- val parentRoute = backStackEntry.destination.parent!!.route!!
- navController.getBackStackEntry(parentRoute)
- }
+ val parentEntry =
+ remember(backStackEntry) {
+ val parentRoute = backStackEntry.destination.parent!!.route!!
+ navController.getBackStackEntry(parentRoute)
+ }
when (nodeDetailRoute) {
NodeDetailRoute.DEVICE -> DeviceMetricsScreen(hiltViewModel(parentEntry))
- NodeDetailRoute.NODE_MAP -> NodeMapScreen(hiltViewModel(parentEntry))
+ NodeDetailRoute.NODE_MAP -> NodeMapScreen(uiViewModel, hiltViewModel(parentEntry))
NodeDetailRoute.POSITION_LOG -> PositionLogScreen(hiltViewModel(parentEntry))
- NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(
- hiltViewModel(
- parentEntry
- )
- )
+ NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry))
- NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(
- viewModel = hiltViewModel(
- parentEntry
- )
- )
+ NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(viewModel = hiltViewModel(parentEntry))
NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.HOST -> HostMetricsLogScreen(hiltViewModel(parentEntry))
@@ -173,11 +134,7 @@ fun NavGraphBuilder.nodeDetailGraph(
}
}
-enum class NodeDetailRoute(
- @StringRes val title: Int,
- val route: Route,
- val icon: ImageVector?,
-) {
+enum class NodeDetailRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?) {
DEVICE(R.string.device, NodeDetailRoutes.DeviceMetrics, Icons.Default.Router),
NODE_MAP(R.string.node_map, NodeDetailRoutes.NodeMap, Icons.Default.LocationOn),
POSITION_LOG(R.string.position_log, NodeDetailRoutes.PositionLog, Icons.Default.LocationOn),
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt
index 15ca5e6a8..3ae785020 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt
@@ -178,12 +178,12 @@ fun ConnectionsScreen(
val isGpsDisabled = context.gpsDisabled()
LaunchedEffect(isGpsDisabled) {
if (isGpsDisabled) {
- uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
+ uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
}
}
LaunchedEffect(bluetoothEnabled) {
if (!bluetoothEnabled) {
- uiViewModel.showSnackbar(context.getString(R.string.bluetooth_disabled))
+ uiViewModel.showSnackBar(context.getString(R.string.bluetooth_disabled))
}
}
// when scanning is true - wait 10000ms and then stop scanning
@@ -234,7 +234,7 @@ fun ConnectionsScreen(
if (!isGpsDisabled) {
uiViewModel.meshService?.startProvideLocation()
} else {
- uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
+ uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
}
} else {
// Request permissions if not granted and user wants to provide location
@@ -575,7 +575,7 @@ fun ConnectionsScreen(
onClick = {
showReportBugDialog = false
reportError("Clicked Report A Bug")
- uiViewModel.showSnackbar("Bug report sent!")
+ uiViewModel.showSnackBar("Bug report sent!")
},
) {
Text(stringResource(R.string.report))
@@ -619,6 +619,7 @@ private enum class DeviceType {
NO_DEVICE_SELECTED -> null
else -> null
}
+
else -> null
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt
new file mode 100644
index 000000000..45040ecfa
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.ui.map
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.geeksville.mesh.database.NodeRepository
+import com.geeksville.mesh.database.PacketRepository
+import com.geeksville.mesh.database.entity.Packet
+import com.geeksville.mesh.model.Node
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+
+@Suppress("TooManyFunctions")
+abstract class BaseMapViewModel(
+ protected val preferences: SharedPreferences,
+ nodeRepository: NodeRepository,
+ packetRepository: PacketRepository,
+) : ViewModel() {
+
+ val nodes: StateFlow> =
+ nodeRepository
+ .getNodes()
+ .map { nodes -> nodes.filterNot { node -> node.isIgnored } }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = emptyList(),
+ )
+
+ val waypoints: StateFlow