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 + + + + + + + + + + + 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> = + packetRepository + .getWaypoints() + .mapLatest { list -> + list + .associateBy { packet -> packet.data.waypoint!!.id } + .filterValues { + it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 + } + } + .stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyMap()) + + private val showOnlyFavorites = 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)) + + fun toggleOnlyFavorites() { + val current = showOnlyFavorites.value + preferences.edit { putBoolean("only-favorites", !current) } + showOnlyFavorites.value = !current + } + + fun toggleShowWaypointsOnMap() { + val current = showWaypointsOnMap.value + preferences.edit { putBoolean("show-waypoints-on-map", !current) } + showWaypointsOnMap.value = !current + } + + fun toggleShowPrecisionCircleOnMap() { + val current = showPrecisionCircleOnMap.value + preferences.edit { putBoolean("show-precision-circle-on-map", !current) } + showPrecisionCircleOnMap.value = !current + } + + data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean) + + val mapFilterStateFlow: StateFlow = + combine(showOnlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) { + favoritesOnly, + showWaypoints, + showPrecisionCircle, + -> + MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = + MapFilterState(showOnlyFavorites.value, showWaypointsOnMap.value, showPrecisionCircleOnMap.value), + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt b/app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt new file mode 100644 index 000000000..feb547dd9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt @@ -0,0 +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.ui.map + +const val MAP_STYLE_ID = "map_style_id" diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/PositionLog.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/PositionLog.kt index 5bc7334d5..9ea4a6e63 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/PositionLog.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/PositionLog.kt @@ -102,17 +102,12 @@ private fun HeaderItem(compactWidth: Boolean) { } } -private const val DEG_D = 1e-7 -private const val HEADING_DEG = 1e-5 +const val DEG_D = 1e-7 +const val HEADING_DEG = 1e-5 private const val SECONDS_TO_MILLIS = 1000L @Composable -private fun PositionItem( - compactWidth: Boolean, - position: MeshProtos.Position, - dateFormat: DateFormat, - system: DisplayUnits, -) { +fun PositionItem(compactWidth: Boolean, position: MeshProtos.Position, dateFormat: DateFormat, system: DisplayUnits) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -130,7 +125,7 @@ private fun PositionItem( } @Composable -private fun formatPositionTime(position: MeshProtos.Position, dateFormat: DateFormat): String { +fun formatPositionTime(position: MeshProtos.Position, dateFormat: DateFormat): String { val currentTime = System.currentTimeMillis() val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds val isOlderThanSixMonths = position.time * SECONDS_TO_MILLIS < sixMonthsAgo diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt index bdf68ccda..7bcee7b30 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt @@ -115,7 +115,6 @@ fun NodeScreen( modifier = Modifier.animateItem(), thisNode = ourNode, thatNode = node, - gpsFormat = state.gpsFormat, distanceUnits = state.distanceUnits, tempInFahrenheit = state.tempInFahrenheit, onAction = { menuItem -> diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/LinkedCoordinates.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/LinkedCoordinates.kt index 686b05b05..d1a1c0862 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/components/LinkedCoordinates.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/LinkedCoordinates.kt @@ -39,10 +39,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.net.toUri -import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat import com.geeksville.mesh.android.BuildUtils.debug import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.common.theme.HyperlinkBlue @@ -52,91 +49,61 @@ import java.net.URLEncoder @OptIn(ExperimentalFoundationApi::class) @Composable -fun LinkedCoordinates( - modifier: Modifier = Modifier, - latitude: Double, - longitude: Double, - format: Int, - nodeName: String, -) { +fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude: Double, nodeName: String) { val context = LocalContext.current val clipboard: Clipboard = LocalClipboard.current val coroutineScope = rememberCoroutineScope() - val style = SpanStyle( - color = HyperlinkBlue, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - textDecoration = TextDecoration.Underline - ) + val style = + SpanStyle( + color = HyperlinkBlue, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + textDecoration = TextDecoration.Underline, + ) - val annotatedString = rememberAnnotatedString(latitude, longitude, format, nodeName, style) + val annotatedString = rememberAnnotatedString(latitude, longitude, nodeName, style) Text( - modifier = modifier.combinedClickable( - onClick = { - handleClick(context, annotatedString) - }, + modifier = + modifier.combinedClickable( + onClick = { handleClick(context, annotatedString) }, onLongClick = { coroutineScope.launch { - clipboard.setClipEntry( - ClipEntry( - ClipData.newPlainText("", annotatedString) - ) - ) + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", annotatedString))) debug("Copied to clipboard") } - } + }, ), - text = annotatedString + text = annotatedString, ) } @Composable -private fun rememberAnnotatedString( - latitude: Double, - longitude: Double, - format: Int, - nodeName: String, - style: SpanStyle -) = buildAnnotatedString { - pushStringAnnotation( - tag = "gps", - annotation = "geo:0,0?q=$latitude,$longitude&z=17&label=${ - URLEncoder.encode(nodeName, "utf-8") - }" - ) - withStyle(style = style) { - val gpsString = when (format) { - GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude) - GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude) - GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude) - GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude) - else -> GPSFormat.toDEC(latitude, longitude) +private fun rememberAnnotatedString(latitude: Double, longitude: Double, nodeName: String, style: SpanStyle) = + buildAnnotatedString { + pushStringAnnotation( + tag = "gps", + annotation = + "geo:0,0?q=$latitude,$longitude&z=17&label=${ + URLEncoder.encode(nodeName, "utf-8") + }", + ) + withStyle(style = style) { + val gpsString = GPSFormat.toDec(latitude, longitude) + append(gpsString) } - append(gpsString) + pop() } - pop() -} private fun handleClick(context: Context, annotatedString: AnnotatedString) { - annotatedString.getStringAnnotations( - tag = "gps", - start = 0, - end = annotatedString.length - ).firstOrNull()?.let { + annotatedString.getStringAnnotations(tag = "gps", start = 0, end = annotatedString.length).firstOrNull()?.let { val uri = it.item.toUri() - val intent = Intent(Intent.ACTION_VIEW, uri).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } + val intent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } try { if (intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) } else { - Toast.makeText( - context, - "No application available to open this location!", - Toast.LENGTH_LONG - ).show() + Toast.makeText(context, "No application available to open this location!", Toast.LENGTH_LONG).show() } } catch (ex: ActivityNotFoundException) { debug("Failed to open geo intent: $ex") @@ -146,20 +113,6 @@ private fun handleClick(context: Context, annotatedString: AnnotatedString) { @PreviewLightDark @Composable -fun LinkedCoordinatesPreview( - @PreviewParameter(GPSFormatPreviewParameterProvider::class) format: Int -) { - AppTheme { - LinkedCoordinates( - latitude = 37.7749, - longitude = -122.4194, - format = format, - nodeName = "Test Node Name" - ) - } -} - -class GPSFormatPreviewParameterProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf(0, 1, 2) +fun LinkedCoordinatesPreview() { + AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt index fd903c917..6292af982 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt @@ -49,6 +49,7 @@ import com.geeksville.mesh.model.Node @Composable fun NodeChip( modifier: Modifier = Modifier, + enabled: Boolean = true, node: Node, isThisNode: Boolean, isConnected: Boolean, @@ -87,6 +88,7 @@ fun NodeChip( modifier = Modifier.matchParentSize() .combinedClickable( + enabled = enabled, onClick = { onAction(NodeMenuAction.MoreDetails(node)) }, onLongClick = { menuExpanded = true }, interactionSource = inputChipInteractionSource, diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt index 0cc2873b5..94960f84c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeItem.kt @@ -65,7 +65,6 @@ import com.geeksville.mesh.util.toDistanceString fun NodeItem( thisNode: Node?, thatNode: Node, - gpsFormat: Int, distanceUnits: Int, tempInFahrenheit: Boolean, modifier: Modifier = Modifier, @@ -79,76 +78,64 @@ fun NodeItem( val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) } val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num } val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) } - val distance = remember(thisNode, thatNode) { - thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) - } + val distance = + remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) } - val hwInfoString = when (val hwModel = thatNode.user.hwModel) { - MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name - else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() - } - val roleName = if (thatNode.isUnknownUser) { - DeviceConfig.Role.UNRECOGNIZED.name - } else { - thatNode.user.role.name - } + val hwInfoString = + when (val hwModel = thatNode.user.hwModel) { + MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name + else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() + } + val roleName = + if (thatNode.isUnknownUser) { + DeviceConfig.Role.UNRECOGNIZED.name + } else { + thatNode.user.role.name + } - val style = if (thatNode.isUnknownUser) { - LocalTextStyle.current.copy(fontStyle = FontStyle.Italic) - } else { - LocalTextStyle.current - } + val style = + if (thatNode.isUnknownUser) { + LocalTextStyle.current.copy(fontStyle = FontStyle.Italic) + } else { + LocalTextStyle.current + } - val cardColors = if (isThisNode) { - thisNode?.colors?.second - } else { - thatNode.colors.second - }?.let { - val containerColor = Color(it).copy(alpha = 0.2f) - CardDefaults.cardColors().copy( - containerColor = containerColor, - contentColor = contentColorFor(containerColor) - ) - } ?: (CardDefaults.cardColors()) + val cardColors = + if (isThisNode) { + thisNode?.colors?.second + } else { + thatNode.colors.second + } + ?.let { + val containerColor = Color(it).copy(alpha = 0.2f) + CardDefaults.cardColors() + .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor)) + } ?: (CardDefaults.cardColors()) val (detailsShown, showDetails) = remember { mutableStateOf(expanded) } - val unmessageable = remember(thatNode) { - when { - thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable - else -> thatNode.user.role.isUnmessageableRole() + val unmessageable = + remember(thatNode) { + when { + thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable + else -> thatNode.user.role.isUnmessageableRole() + } } - } Card( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .defaultMinSize(minHeight = 80.dp), + modifier = + modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).defaultMinSize(minHeight = 80.dp), onClick = { showDetails(!detailsShown) }, - colors = cardColors + colors = cardColors, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - NodeChip( - node = thatNode, - isThisNode = isThisNode, - isConnected = isConnected, - onAction = onAction, - ) + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + NodeChip(node = thatNode, isThisNode = isThisNode, isConnected = isConnected, onAction = onAction) NodeKeyStatusIcon( hasPKC = thatNode.hasPKC, mismatchKey = thatNode.mismatchKey, publicKey = thatNode.user.publicKey, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) Text( modifier = Modifier.weight(1f), @@ -157,34 +144,21 @@ fun NodeItem( textDecoration = TextDecoration.LineThrough.takeIf { isIgnored }, softWrap = true, ) - LastHeardInfo( - lastHeard = thatNode.lastHeard, - currentTimeMillis = currentTimeMillis - ) + LastHeardInfo(lastHeard = thatNode.lastHeard, currentTimeMillis = currentTimeMillis) NodeStatusIcons( isThisNode = isThisNode, isFavorite = isFavorite, isUnmessageable = unmessageable, - isConnected = isConnected + isConnected = isConnected, ) } - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (distance != null) { - Text( - text = distance, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) + Text(text = distance, fontSize = MaterialTheme.typography.labelLarge.fontSize) } else { Spacer(modifier = Modifier.width(16.dp)) } - BatteryInfo( - batteryLevel = thatNode.batteryLevel, - voltage = thatNode.voltage - ) + BatteryInfo(batteryLevel = thatNode.batteryLevel, voltage = thatNode.voltage) } Spacer(modifier = Modifier.height(4.dp)) Row( @@ -192,10 +166,7 @@ fun NodeItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - SignalInfo( - node = thatNode, - isThisNode = isThisNode - ) + SignalInfo(node = thatNode, isThisNode = isThisNode) thatNode.validPosition?.let { position -> val satCount = position.satsInView if (satCount > 0) { @@ -204,10 +175,7 @@ fun NodeItem( } } Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { val telemetryString = thatNode.getTelemetryString(tempInFahrenheit) if (telemetryString.isNotEmpty()) { Text( @@ -222,31 +190,24 @@ fun NodeItem( Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider() Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { thatNode.validPosition?.let { LinkedCoordinates( latitude = thatNode.latitude, longitude = thatNode.longitude, - format = gpsFormat, - nodeName = longName + nodeName = longName, ) } thatNode.validPosition?.let { position -> ElevationInfo( altitude = position.altitude, system = system, - suffix = stringResource(id = R.string.elevation_suffix) + suffix = stringResource(id = R.string.elevation_suffix), ) } } Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - ) { + Row(modifier = Modifier.fillMaxWidth()) { Text( modifier = Modifier.weight(1f), text = hwInfoString, @@ -279,50 +240,29 @@ fun NodeInfoSimplePreview() { AppTheme { val thisNode = NodePreviewParameterProvider().values.first() val thatNode = NodePreviewParameterProvider().values.last() - NodeItem( - thisNode = thisNode, - thatNode = thatNode, - 1, - 0, - true, - currentTimeMillis = System.currentTimeMillis(), - ) + NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, currentTimeMillis = System.currentTimeMillis()) } } @Composable -@Preview( - showBackground = true, - uiMode = Configuration.UI_MODE_NIGHT_YES, -) -fun NodeInfoPreview( - @PreviewParameter(NodePreviewParameterProvider::class) - thatNode: Node -) { +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) { AppTheme { val thisNode = NodePreviewParameterProvider().values.first() Column { - Text( - text = "Details Collapsed", - color = MaterialTheme.colorScheme.onBackground - ) + Text(text = "Details Collapsed", color = MaterialTheme.colorScheme.onBackground) NodeItem( thisNode = thisNode, thatNode = thatNode, - gpsFormat = 0, distanceUnits = 1, tempInFahrenheit = true, expanded = false, currentTimeMillis = System.currentTimeMillis(), ) - Text( - text = "Details Shown", - color = MaterialTheme.colorScheme.onBackground - ) + Text(text = "Details Shown", color = MaterialTheme.colorScheme.onBackground) NodeItem( thisNode = thisNode, thatNode = thatNode, - gpsFormat = 0, distanceUnits = 1, tempInFahrenheit = true, expanded = true, diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/DisplayConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/DisplayConfigItemList.kt index 41fa7fe86..d5b9c441a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/DisplayConfigItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/DisplayConfigItemList.kt @@ -44,16 +44,11 @@ import com.geeksville.mesh.ui.common.components.SwitchPreference import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel @Composable -fun DisplayConfigScreen( - viewModel: RadioConfigViewModel = hiltViewModel(), -) { +fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel()) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() if (state.responseState.isWaiting()) { - PacketResponseStateDialog( - state = state.responseState, - onDismiss = viewModel::clearPacketResponse, - ) + PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse) } DisplayConfigItemList( @@ -62,22 +57,17 @@ fun DisplayConfigScreen( onSaveClicked = { displayInput -> val config = config { display = displayInput } viewModel.setConfig(config) - } + }, ) } +@Suppress("LongMethod") @Composable -fun DisplayConfigItemList( - displayConfig: DisplayConfig, - enabled: Boolean, - onSaveClicked: (DisplayConfig) -> Unit, -) { +fun DisplayConfigItemList(displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -> Unit) { val focusManager = LocalFocusManager.current var displayInput by rememberSaveable { mutableStateOf(displayConfig) } - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { item { PreferenceCategory(text = stringResource(R.string.display_config)) } item { @@ -86,21 +76,10 @@ fun DisplayConfigItemList( value = displayInput.screenOnSecs, enabled = enabled, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } } + onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } }, ) } - item { - DropDownPreference( - title = stringResource(R.string.gps_coordinates_format), - enabled = enabled, - items = DisplayConfig.GpsCoordinateFormat.entries - .filter { it != DisplayConfig.GpsCoordinateFormat.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = displayInput.gpsFormat, - onItemSelected = { displayInput = displayInput.copy { gpsFormat = it } } - ) - } item { HorizontalDivider() } item { @@ -109,9 +88,7 @@ fun DisplayConfigItemList( value = displayInput.autoScreenCarouselSecs, enabled = enabled, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - displayInput = displayInput.copy { autoScreenCarouselSecs = it } - } + onValueChanged = { displayInput = displayInput.copy { autoScreenCarouselSecs = it } }, ) } @@ -120,7 +97,7 @@ fun DisplayConfigItemList( title = stringResource(R.string.compass_north_top), checked = displayInput.compassNorthTop, enabled = enabled, - onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } } + onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } }, ) } item { HorizontalDivider() } @@ -130,7 +107,7 @@ fun DisplayConfigItemList( title = stringResource(R.string.flip_screen), checked = displayInput.flipScreen, enabled = enabled, - onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } } + onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } }, ) } item { HorizontalDivider() } @@ -139,11 +116,12 @@ fun DisplayConfigItemList( DropDownPreference( title = stringResource(R.string.display_units), enabled = enabled, - items = DisplayConfig.DisplayUnits.entries + items = + DisplayConfig.DisplayUnits.entries .filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED } .map { it to it.name }, selectedItem = displayInput.units, - onItemSelected = { displayInput = displayInput.copy { units = it } } + onItemSelected = { displayInput = displayInput.copy { units = it } }, ) } item { HorizontalDivider() } @@ -152,11 +130,12 @@ fun DisplayConfigItemList( DropDownPreference( title = stringResource(R.string.override_oled_auto_detect), enabled = enabled, - items = DisplayConfig.OledType.entries + items = + DisplayConfig.OledType.entries .filter { it != DisplayConfig.OledType.UNRECOGNIZED } .map { it to it.name }, selectedItem = displayInput.oled, - onItemSelected = { displayInput = displayInput.copy { oled = it } } + onItemSelected = { displayInput = displayInput.copy { oled = it } }, ) } item { HorizontalDivider() } @@ -165,11 +144,12 @@ fun DisplayConfigItemList( DropDownPreference( title = stringResource(R.string.display_mode), enabled = enabled, - items = DisplayConfig.DisplayMode.entries + items = + DisplayConfig.DisplayMode.entries .filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED } .map { it to it.name }, selectedItem = displayInput.displaymode, - onItemSelected = { displayInput = displayInput.copy { displaymode = it } } + onItemSelected = { displayInput = displayInput.copy { displaymode = it } }, ) } item { HorizontalDivider() } @@ -179,7 +159,7 @@ fun DisplayConfigItemList( title = stringResource(R.string.heading_bold), checked = displayInput.headingBold, enabled = enabled, - onCheckedChange = { displayInput = displayInput.copy { headingBold = it } } + onCheckedChange = { displayInput = displayInput.copy { headingBold = it } }, ) } item { HorizontalDivider() } @@ -189,7 +169,7 @@ fun DisplayConfigItemList( title = stringResource(R.string.wake_screen_on_tap_or_motion), checked = displayInput.wakeOnTapOrMotion, enabled = enabled, - onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } } + onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } }, ) } item { HorizontalDivider() } @@ -198,11 +178,12 @@ fun DisplayConfigItemList( DropDownPreference( title = stringResource(R.string.compass_orientation), enabled = enabled, - items = DisplayConfig.CompassOrientation.entries + items = + DisplayConfig.CompassOrientation.entries .filter { it != DisplayConfig.CompassOrientation.UNRECOGNIZED } .map { it to it.name }, selectedItem = displayInput.compassOrientation, - onItemSelected = { displayInput = displayInput.copy { compassOrientation = it } } + onItemSelected = { displayInput = displayInput.copy { compassOrientation = it } }, ) } item { HorizontalDivider() } @@ -213,7 +194,7 @@ fun DisplayConfigItemList( summary = stringResource(R.string.display_time_in_12h_format), enabled = enabled, checked = displayInput.use12HClock, - onCheckedChange = { displayInput = displayInput.copy { use12HClock = it } } + onCheckedChange = { displayInput = displayInput.copy { use12HClock = it } }, ) } item { HorizontalDivider() } @@ -228,7 +209,7 @@ fun DisplayConfigItemList( onSaveClicked = { focusManager.clearFocus() onSaveClicked(displayInput) - } + }, ) } } @@ -237,9 +218,5 @@ fun DisplayConfigItemList( @Preview(showBackground = true) @Composable private fun DisplayConfigPreview() { - DisplayConfigItemList( - displayConfig = DisplayConfig.getDefaultInstance(), - enabled = true, - onSaveClicked = { }, - ) + DisplayConfigItemList(displayConfig = DisplayConfig.getDefaultInstance(), enabled = true, onSaveClicked = {}) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index 66c1b7d88..90cad2bb5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -211,7 +211,7 @@ fun ChannelScreen( channelSet = channels // Throw away user edits // Tell the user to try again - viewModel.showSnackbar(R.string.cant_change_no_radio) + viewModel.showSnackBar(R.string.cant_change_no_radio) } } diff --git a/app/src/main/java/com/geeksville/mesh/util/Extensions.kt b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt index 9a1067dee..9162f740c 100644 --- a/app/src/main/java/com/geeksville/mesh/util/Extensions.kt +++ b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt @@ -72,6 +72,21 @@ fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMil } } +private const val MPS_TO_KMPH = 3.6f +private const val KM_TO_MILES = 0.621371f + +fun Int.mpsToKmph(): Float { + // Convert meters per second to kilometers per hour + val kmph = this * MPS_TO_KMPH + return kmph +} + +fun Int.mpsToMph(): Float { + // Convert meters per second to miles per hour + val mph = this * MPS_TO_KMPH * KM_TO_MILES + return mph +} + // Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() }) fun EditText.onEditorAction(actionId: Int, func: () -> Unit) { setOnEditorActionListener { _, receivedActionId, _ -> diff --git a/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt b/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt index bb4567e63..955d4e013 100644 --- a/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt +++ b/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt @@ -17,311 +17,64 @@ package com.geeksville.mesh.util -import com.geeksville.mesh.MeshProtos +import android.annotation.SuppressLint import com.geeksville.mesh.Position -import mil.nga.grid.features.Point -import mil.nga.mgrs.MGRS -import mil.nga.mgrs.utm.UTM -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.abs -import kotlin.math.acos +import java.util.Locale +import kotlin.math.asin import kotlin.math.atan2 import kotlin.math.cos -import kotlin.math.log2 import kotlin.math.pow import kotlin.math.sin -import kotlin.math.PI - -/******************************************************************************* - * Revive some of my old Gaggle source code... - * - * GNU Public License, version 2 - * All other distribution of Gaggle must conform to the terms of the GNU Public License, version 2. The full - * text of this license is included in the Gaggle source, see assets/manual/gpl-2.0.txt. - ******************************************************************************/ +import kotlin.math.sqrt +@SuppressLint("PropertyNaming") object GPSFormat { - fun DEC(p: Position): String { - return String.format("%.5f %.5f", p.latitude, p.longitude).replace(",", ".") - } - - fun DMS(p: Position): String { - val lat = degreesToDMS(p.latitude, true) - val lon = degreesToDMS(p.longitude, false) - fun string(a: Array) = String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3]) - return string(lat) + " " + string(lon) - } - - fun UTM(p: Position): String { - val UTM = UTM.from(Point.point(p.longitude, p.latitude)) - return String.format( - "%s%s %.6s %.7s", - UTM.zone, - UTM.toMGRS().band, - UTM.easting, - UTM.northing - ) - } - - fun MGRS(p: Position): String { - val MGRS = MGRS.from(Point.point(p.longitude, p.latitude)) - return String.format( - "%s%s %s%s %05d %05d", - MGRS.zone, - MGRS.band, - MGRS.column, - MGRS.row, - MGRS.easting, - MGRS.northing - ) - } - - fun toDEC(latitude: Double, longitude: Double): String { - return "%.5f %.5f".format(latitude, longitude).replace(",", ".") - } - - fun toDMS(latitude: Double, longitude: Double): String { - val lat = degreesToDMS(latitude, true) - val lon = degreesToDMS(longitude, false) - fun string(a: Array) = "%s°%s'%.5s\"%s".format(a[0], a[1], a[2], a[3]) - return string(lat) + " " + string(lon) - } - - fun toUTM(latitude: Double, longitude: Double): String { - val UTM = UTM.from(Point.point(longitude, latitude)) - return "%s%s %.6s %.7s".format(UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing) - } - - fun toMGRS(latitude: Double, longitude: Double): String { - val MGRS = MGRS.from(Point.point(longitude, latitude)) - return "%s%s %s%s %05d %05d".format( - MGRS.zone, - MGRS.band, - MGRS.column, - MGRS.row, - MGRS.easting, - MGRS.northing - ) - } + fun toDec(latitude: Double, longitude: Double): String = + String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude) } -/** - * Format as degrees, minutes, secs - * - * @param degIn - * @param isLatitude - * @return a string like 120deg - */ -fun degreesToDMS( - _degIn: Double, - isLatitude: Boolean -): Array { - var degIn = _degIn - val isPos = degIn >= 0 - val dirLetter = - if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' - degIn = abs(degIn) - val degOut = degIn.toInt() - val minutes = 60 * (degIn - degOut) - val minwhole = minutes.toInt() - val seconds = (minutes - minwhole) * 60 - return arrayOf( - degOut.toString(), minwhole.toString(), - seconds.toString(), - dirLetter.toString() - ) -} +private const val EARTH_RADIUS_METERS = 6371e3 -fun degreesToDM(_degIn: Double, isLatitude: Boolean): Array { - var degIn = _degIn - val isPos = degIn >= 0 - val dirLetter = - if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' - degIn = abs(degIn) - val degOut = degIn.toInt() - val minutes = 60 * (degIn - degOut) - val seconds = 0 - return arrayOf( - degOut.toString(), minutes.toString(), - seconds.toString(), - dirLetter.toString() - ) -} +/** @return distance in meters along the surface of the earth (ish) */ +fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double { + val lat1 = Math.toRadians(latitudeA) + val lon1 = Math.toRadians(longitudeA) + val lat2 = Math.toRadians(latitudeB) + val lon2 = Math.toRadians(longitudeB) -fun degreesToD(_degIn: Double, isLatitude: Boolean): Array { - var degIn = _degIn - val isPos = degIn >= 0 - val dirLetter = - if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' - degIn = abs(degIn) - val degOut = degIn - val minutes = 0 - val seconds = 0 - return arrayOf( - degOut.toString(), minutes.toString(), - seconds.toString(), - dirLetter.toString() - ) -} + val dLat = lat2 - lat1 + val dLon = lon2 - lon1 -/** - * A not super efficent mapping from a starting lat/long + a distance at a - * certain direction - * - * @param lat - * @param longitude - * @param distMeters - * @param theta - * in radians, 0 == north - * @return an array with lat and long - */ -fun addDistance( - lat: Double, - longitude: Double, - distMeters: Double, - theta: Double -): DoubleArray { - val dx = distMeters * sin(theta) // theta measured clockwise - // from due north - val dy = distMeters * cos(theta) // dx, dy same units as R - val dLong = dx / (111320 * cos(lat)) // dx, dy in meters - val dLat = dy / 110540 // result in degrees long/lat - return doubleArrayOf(lat + dLat, longitude + dLong) -} + val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2) + val c = 2 * asin(sqrt(a)) -/** - * @return distance in meters along the surface of the earth (ish) - */ -fun latLongToMeter( - lat_a: Double, - lng_a: Double, - lat_b: Double, - lng_b: Double -): Double { - val pk = (180 / PI) - val a1 = lat_a / pk - val a2 = lng_a / pk - val b1 = lat_b / pk - val b2 = lng_b / pk - val t1 = cos(a1) * cos(a2) * cos(b1) * cos(b2) - val t2 = cos(a1) * sin(a2) * cos(b1) * sin(b2) - val t3 = sin(a1) * sin(b1) - var tt = acos(t1 + t2 + t3) - if (java.lang.Double.isNaN(tt)) tt = 0.0 // Must have been the same point? - return 6366000 * tt + return EARTH_RADIUS_METERS * c } // Same as above, but takes Mesh Position proto. -fun positionToMeter(a: MeshProtos.Position, b: MeshProtos.Position): Double { - return latLongToMeter( - a.latitudeI * 1e-7, - a.longitudeI * 1e-7, - b.latitudeI * 1e-7, - b.longitudeI * 1e-7 - ) -} - -/** - * Convert degrees/mins/secs to a single double - * - * @param degrees - * @param minutes - * @param seconds - * @param isPostive - * @return - */ -fun DMSToDegrees( - degrees: Int, - minutes: Int, - seconds: Float, - isPostive: Boolean -): Double { - return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0) -} - -fun DMSToDegrees( - degrees: Double, - minutes: Double, - seconds: Double, - isPostive: Boolean -): Double { - return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0) -} +fun positionToMeter(a: Position, b: Position): Double = + latLongToMeter(a.latitude * 1e-7, a.longitude * 1e-7, b.latitude * 1e-7, b.longitude * 1e-7) /** * Computes the bearing in degrees between two points on Earth. * - * @param lat1 - * Latitude of the first point - * @param lon1 - * Longitude of the first point - * @param lat2 - * Latitude of the second point - * @param lon2 - * Longitude of the second point - * @return Bearing between the two points in degrees. A value of 0 means due - * north. + * @param lat1 Latitude of the first point + * @param lon1 Longitude of the first point + * @param lat2 Latitude of the second point + * @param lon2 Longitude of the second point + * @return Bearing between the two points in degrees. A value of 0 means due north. */ -fun bearing( - lat1: Double, - lon1: Double, - lat2: Double, - lon2: Double -): Double { +fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val lat1Rad = Math.toRadians(lat1) + val lon1Rad = Math.toRadians(lon1) val lat2Rad = Math.toRadians(lat2) - val deltaLonRad = Math.toRadians(lon2 - lon1) - val y = sin(deltaLonRad) * cos(lat2Rad) - val x = cos(lat1Rad) * sin(lat2Rad) - (sin(lat1Rad) * cos(lat2Rad) * cos(deltaLonRad)) - return radToBearing(atan2(y, x)) -} - -/** - * Converts an angle in radians to degrees - */ -fun radToBearing(rad: Double): Double { - return (Math.toDegrees(rad) + 360) % 360 -} - -/** - * 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(360.0 / (latLonHeight / 111320)) - val requiredLonZoom = log2(360.0 / (latLonWidth / 111320)) - return maxOf(requiredLatZoom, requiredLonZoom) * 0.8 -} - -/** - * 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 - ) + val lon2Rad = Math.toRadians(lon2) + + val dLon = lon2Rad - lon1Rad + + val y = sin(dLon) * cos(lat2Rad) + val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon) + val bearing = Math.toDegrees(atan2(y, x)) + + return (bearing + 360) % 360 } diff --git a/app/src/main/java/com/geeksville/mesh/util/SharedPreferenceExtensions.kt b/app/src/main/java/com/geeksville/mesh/util/SharedPreferenceExtensions.kt new file mode 100644 index 000000000..04158b755 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/SharedPreferenceExtensions.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.util + +import android.content.SharedPreferences +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow + +fun SharedPreferences.toggleBooleanPreference( + state: MutableStateFlow, + key: String, + onChanged: (Boolean) -> Unit = {}, +) { + val newValue = !state.value + state.value = newValue + this.edit { putBoolean(key, newValue) } + onChanged(newValue) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2ab5c9e4..828b01baf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -763,4 +763,30 @@ %d nodes queued for deletion: Caution: This removes nodes from in-app and on-device databases.\nSelections are additive. Connecting to device + Normal + Satellite + Terrain + Hybrid + Manage Map Layers + Map Layers + No custom layers loaded. + Add Layer + Hide Layer + Show Layer + Remove Layer + Add Layer + Nodes at this location + Selected Map Type + Manage Custom Tile Sources + Add Custom Tile Source + No Custom Tile Sources + Edit Custom Tile Source + Delete Custom Tile Source + Name cannot be empty. + Provider name exists. + URL cannot be empty. + URL must contain placeholders. + URL Template + https://a.tile.openstreetmap.org/{z}/{x}/{y}.png + track point diff --git a/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt b/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt index 94871d483..d017a6865 100644 --- a/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt +++ b/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt @@ -23,17 +23,18 @@ import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Test -import java.util.* +import java.util.Locale class NodeInfoTest { private val model = MeshProtos.HardwareModel.ANDROID_SIM - private val node = listOf( - NodeInfo(4, MeshUser("+zero", "User Zero", "U0", model)), - NodeInfo(5, MeshUser("+one", "User One", "U1", model), Position(37.1, 121.1, 35)), - NodeInfo(6, MeshUser("+two", "User Two", "U2", model), Position(37.11, 121.1, 40)), - NodeInfo(7, MeshUser("+three", "User Three", "U3", model), Position(37.101, 121.1, 40)), - NodeInfo(8, MeshUser("+four", "User Four", "U4", model), Position(37.116, 121.1, 40)), - ) + private val node = + listOf( + NodeInfo(4, MeshUser("+zero", "User Zero", "U0", model)), + NodeInfo(5, MeshUser("+one", "User One", "U1", model), Position(37.1, 121.1, 35)), + NodeInfo(6, MeshUser("+two", "User Two", "U2", model), Position(37.11, 121.1, 40)), + NodeInfo(7, MeshUser("+three", "User Three", "U3", model), Position(37.101, 121.1, 40)), + NodeInfo(8, MeshUser("+four", "User Four", "U4", model), Position(37.116, 121.1, 40)), + ) private val currentDefaultLocale = LocaleListCompat.getDefault().get(0) ?: Locale.US @@ -51,7 +52,7 @@ class NodeInfoTest { fun distanceGood() { Assert.assertEquals(node[1].distance(node[2]), 1111) Assert.assertEquals(node[1].distance(node[3]), 111) - Assert.assertEquals(node[1].distance(node[4]), 1777) + Assert.assertEquals(node[1].distance(node[4]), 1779) } @Test diff --git a/build.gradle.kts b/build.gradle.kts index 89544f76b..0e50579b5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ buildscript { classpath(libs.kotlin.serialization) classpath(libs.google.services) classpath(libs.firebase.crashlytics.gradle) + classpath(libs.secrets.gradle.plugin) classpath(libs.protobuf.gradle.plugin) classpath(libs.hilt.android.gradle.plugin) classpath(libs.secrets.gradle.plugin) diff --git a/buildSrc/src/main/kotlin/Configs.kt b/buildSrc/src/main/kotlin/Configs.kt index c259a663d..64d00e432 100644 --- a/buildSrc/src/main/kotlin/Configs.kt +++ b/buildSrc/src/main/kotlin/Configs.kt @@ -17,7 +17,7 @@ object Configs { const val APPLICATION_ID = "com.geeksville.mesh" - const val MIN_SDK_VERSION = 26 + const val MIN_SDK = 26 const val TARGET_SDK = 36 const val COMPILE_SDK = 36 const val VERSION_NAME_BASE = "2.6.34" diff --git a/config/detekt/detekt-baseline.xml b/config/detekt/detekt-baseline.xml index 19b5accac..3d38b69f7 100644 --- a/config/detekt/detekt-baseline.xml +++ b/config/detekt/detekt-baseline.xml @@ -1,21 +1,4 @@ - - TooManyFunctions:ContactSharing.kt$com.geeksville.mesh.ui.ContactSharing.kt @@ -23,66 +6,19 @@ ChainWrapping:Channel.kt$Channel$&& - ChainWrapping:CustomTileSource.kt$CustomTileSource.Companion.<no name provided>$+ - ChainWrapping:SqlTileWriterExt.kt$SqlTileWriterExt$+ - CommentSpacing:AppIntroduction.kt$AppIntroduction$//addSlide(SlideTwoFragment()) CommentSpacing:BLEException.kt$BLEConnectionClosing$/// Our interface is being shut down - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Attempt to read from the fromRadio mailbox, if data is found broadcast it to android apps - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// For testing - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Our BLE device - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Our service - note - it is possible to get back a null response for getService if the device services haven't yet been found - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Send a packet/command out the radio link - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Start a connection attempt - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// We gracefully handle safe being null because this can occur if someone has unpaired from our device - just abandon the reconnect attempt - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// We only force service refresh the _first_ time we connect to the device. Thereafter it is assumed the firmware didn't change - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// We only try to set MTU once, because some buggy implementations fail - CommentSpacing:BluetoothInterface.kt$BluetoothInterface$//needForceRefresh = false // In fact, because of tearing down BLE in sleep on the ESP32, our handle # assignments are not stable across sleep - so we much refetch every time - CommentSpacing:BluetoothInterface.kt$BluetoothInterface.Companion$/// this service UUID is publicly visible for scanning CommentSpacing:Constants.kt$/// a bool true means we expect this condition to continue until, false means device might come back CommentSpacing:ContextExtensions.kt$/// Utility function to hide the soft keyboard per stack overflow CommentSpacing:ContextExtensions.kt$/// show a toast CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib CommentSpacing:DeferredExecution.kt$DeferredExecution$/// Queue some new work CommentSpacing:DeferredExecution.kt$DeferredExecution$/// run all work in the queue and clear it to be ready to accept new work - CommentSpacing:DownloadButton.kt$//@Composable - CommentSpacing:DownloadButton.kt$//@Preview(showBackground = true) - CommentSpacing:DownloadButton.kt$//private fun DownloadButtonPreview() { - CommentSpacing:DownloadButton.kt$//} CommentSpacing:Exceptions.kt$/// Convert any exceptions in this service call into a RemoteException that the client can CommentSpacing:Exceptions.kt$/// then handle CommentSpacing:Exceptions.kt$Exceptions$/// Set in Application.onCreate - CommentSpacing:Logging.kt$Logging$/// Kotlin assertions are disabled on android, so instead we use this assert helper - CommentSpacing:Logging.kt$Logging$/// Report an error (including messaging our crash reporter service if allowed - CommentSpacing:Logging.kt$Logging.Companion$/// If false debug logs will not be shown (but others might) - CommentSpacing:Logging.kt$Logging.Companion$/// if false NO logs will be shown, set this in the application based on BuildConfig.DEBUG - CommentSpacing:MeshServiceStarter.kt$/// Helper function to start running our service CommentSpacing:MockInterface.kt$MockInterface$/// Generate a fake node info entry CommentSpacing:MockInterface.kt$MockInterface$/// Generate a fake text message from a node CommentSpacing:MockInterface.kt$MockInterface$/// Send a fake ack packet back if the sender asked for want_ack - CommentSpacing:NOAAWmsTileSource.kt$NOAAWmsTileSource$//array indexes for that data - CommentSpacing:NOAAWmsTileSource.kt$NOAAWmsTileSource$//used by geo server - CommentSpacing:NodeInfo.kt$NodeInfo$/// @return a nice human readable string for the distance, or null for unknown - CommentSpacing:NodeInfo.kt$NodeInfo$/// @return bearing to the other position in degrees - CommentSpacing:NodeInfo.kt$NodeInfo$/// @return distance in meters to some other node (or null if unknown) - CommentSpacing:NodeInfo.kt$NodeInfo$/// return the position if it is valid, else null - CommentSpacing:NodeInfo.kt$Position$/// @return bearing to the other position in degrees - CommentSpacing:NodeInfo.kt$Position$/// @return distance in meters to some other node (or null if unknown) - CommentSpacing:NodeInfo.kt$Position.Companion$/// Convert to a double representation of degrees - CommentSpacing:SafeBluetooth.kt$/// Return a standard BLE 128 bit UUID from the short 16 bit versions - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Drop our current connection and then requeue a connect as needed - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// If we have work we can do, start doing it. - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Restart any previous connect attempts - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Timeout before we declare a bluetooth operation failed (used for synchronous API operations only) - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// True if the current active connection is auto (possible for this to be false but autoConnect to be true - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Users can access the GATT directly as needed - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// asyncronously turn notification on/off for a characteristic - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// from characteristic UUIDs to the handler function for notfies - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// helper glue to make sync continuations and then wait for the result - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// if we are in the first non-automated lowLevel connect. - CommentSpacing:SafeBluetooth.kt$SafeBluetooth$//com.geeksville.mesh.service.SafeBluetooth.closeGatt - CommentSpacing:SafeBluetooth.kt$SafeBluetooth.<no name provided>$//throw Exception("Mystery bluetooth failure - debug me") - CommentSpacing:SafeBluetooth.kt$SafeBluetooth.BluetoothContinuation$/// Connection work items are treated specially - CommentSpacing:SafeBluetooth.kt$SafeBluetooth.BluetoothContinuation$/// Start running a queued bit of work, return true for success or false for fatal bluetooth error ConstructorParameterNaming:MeshLog.kt$MeshLog$@ColumnInfo(name = "message") val raw_message: String ConstructorParameterNaming:MeshLog.kt$MeshLog$@ColumnInfo(name = "received_date") val received_date: Long ConstructorParameterNaming:MeshLog.kt$MeshLog$@ColumnInfo(name = "type") val message_type: String @@ -90,92 +26,60 @@ ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "contact_key") val contact_key: String ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "port_num") val port_num: Int ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "received_time") val received_time: Long - CyclomaticComplexMethod:MapView.kt$@Composable fun MapView( model: UIViewModel = viewModel(), ) CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) - CyclomaticComplexMethod:UIState.kt$UIViewModel$fun saveMessagesCSV(uri: Uri) EmptyCatchBlock:MeshLog.kt$MeshLog${ } EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ } - EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt$() - EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt.SourceCount$() EmptyFunctionBlock:NopInterface.kt$NopInterface${ } EmptyFunctionBlock:NsdManager.kt$<no name provided>${ } EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${} - FinalNewline:AppIntroduction.kt$com.geeksville.mesh.AppIntroduction.kt FinalNewline:AppPrefs.kt$com.geeksville.mesh.android.AppPrefs.kt FinalNewline:ApplicationModule.kt$com.geeksville.mesh.ApplicationModule.kt FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt FinalNewline:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt FinalNewline:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt - FinalNewline:BluetoothViewModel.kt$com.geeksville.mesh.model.BluetoothViewModel.kt FinalNewline:BootCompleteReceiver.kt$com.geeksville.mesh.service.BootCompleteReceiver.kt FinalNewline:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt - FinalNewline:CustomTileSource.kt$com.geeksville.mesh.model.map.CustomTileSource.kt FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt FinalNewline:DeferredExecution.kt$com.geeksville.mesh.concurrent.DeferredExecution.kt FinalNewline:DeviceVersion.kt$com.geeksville.mesh.model.DeviceVersion.kt - FinalNewline:DeviceVersionTest.kt$com.geeksville.mesh.model.DeviceVersionTest.kt - FinalNewline:ExpireChecker.kt$com.geeksville.mesh.android.ExpireChecker.kt FinalNewline:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt FinalNewline:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt - FinalNewline:Logging.kt$com.geeksville.mesh.android.Logging.kt FinalNewline:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt - FinalNewline:NOAAWmsTileSource.kt$com.geeksville.mesh.model.map.NOAAWmsTileSource.kt FinalNewline:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt FinalNewline:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt - FinalNewline:OnlineTileSourceAuth.kt$com.geeksville.mesh.model.map.OnlineTileSourceAuth.kt FinalNewline:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt FinalNewline:QuickChatActionRepository.kt$com.geeksville.mesh.database.QuickChatActionRepository.kt FinalNewline:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt FinalNewline:RadioRepositoryModule.kt$com.geeksville.mesh.repository.radio.RadioRepositoryModule.kt FinalNewline:RegularPreference.kt$com.geeksville.mesh.ui.common.components.RegularPreference.kt - FinalNewline:SafeBluetooth.kt$com.geeksville.mesh.service.SafeBluetooth.kt FinalNewline:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt FinalNewline:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt FinalNewline:SerialInterface.kt$com.geeksville.mesh.repository.radio.SerialInterface.kt FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt - FinalNewline:SqlTileWriterExt.kt$com.geeksville.mesh.util.SqlTileWriterExt.kt FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt FinalNewline:UsbBroadcastReceiver.kt$com.geeksville.mesh.repository.usb.UsbBroadcastReceiver.kt FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt - ForbiddenComment:MapView.kt$// TODO: Accept filename input param from user ForbiddenComment:SafeBluetooth.kt$SafeBluetooth$// TODO: display some kind of UI about restarting BLE FunctionNaming:PacketDao.kt$PacketDao$@Query("DELETE FROM packet WHERE uuid=:uuid") suspend fun _delete(uuid: Long) FunctionNaming:QuickChatActionDao.kt$QuickChatActionDao$@Query("Delete from quick_chat where uuid=:uuid") fun _delete(uuid: Long) - FunctionParameterNaming:LocationUtils.kt$_degIn: Double - FunctionParameterNaming:LocationUtils.kt$lat_a: Double - FunctionParameterNaming:LocationUtils.kt$lat_b: Double - FunctionParameterNaming:LocationUtils.kt$lng_a: Double - FunctionParameterNaming:LocationUtils.kt$lng_b: Double - ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %.6s %.7s", UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing ) - ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %s%s %05d %05d", MGRS.zone, MGRS.band, MGRS.column, MGRS.row, MGRS.easting, MGRS.northing ) - ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%.5f %.5f", p.latitude, p.longitude) - ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3]) ImplicitDefaultLocale:NodeInfo.kt$NodeInfo$String.format("%d%%", batteryLevel) LargeClass:MeshService.kt$MeshService : ServiceLogging LongMethod:AmbientLightingConfigItemList.kt$@Composable fun AmbientLightingConfigItemList( ambientLightingConfig: ModuleConfigProtos.ModuleConfig.AmbientLightingConfig, enabled: Boolean, onSaveClicked: (ModuleConfigProtos.ModuleConfig.AmbientLightingConfig) -> Unit, ) LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigItemList( audioConfig: AudioConfig, enabled: Boolean, onSaveClicked: (AudioConfig) -> Unit, ) LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigItemList( messages: String, cannedMessageConfig: CannedMessageConfig, enabled: Boolean, onSaveClicked: (messages: String, config: CannedMessageConfig) -> Unit, ) - LongMethod:Contacts.kt$@Composable fun ContactsScreen( uiViewModel: UIViewModel = hiltViewModel(), onNavigate: (String) -> Unit = {} ) LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigItemList( deviceConfig: DeviceConfig, enabled: Boolean, onSaveClicked: (DeviceConfig) -> Unit, ) - LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigItemList( displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -> Unit, ) LongMethod:DropDownPreference.kt$@Composable fun <T> DropDownPreference( title: String, enabled: Boolean, items: List<Pair<T, String>>, selectedItem: T, onItemSelected: (T) -> Unit, modifier: Modifier = Modifier, summary: String? = null, ) LongMethod:EditListPreference.kt$@Composable inline fun <reified T> EditListPreference( title: String, list: List<T>, maxCount: Int, enabled: Boolean, keyboardActions: KeyboardActions, crossinline onValuesChanged: (List<T>) -> Unit, modifier: Modifier = Modifier, ) LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigItemList( ringtone: String, extNotificationConfig: ExternalNotificationConfig, enabled: Boolean, onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -> Unit, ) - LongMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigItemList( mqttConfig: MQTTConfig, enabled: Boolean, onSaveClicked: (MQTTConfig) -> Unit, ) - LongMethod:MapView.kt$@Composable fun MapView( model: UIViewModel = viewModel(), ) LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) - LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigItemList( powerConfig: PowerConfig, enabled: Boolean, onSaveClicked: (PowerConfig) -> Unit, ) LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigItemList( serialConfig: SerialConfig, enabled: Boolean, onSaveClicked: (SerialConfig) -> Unit, ) LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigItemList( storeForwardConfig: StoreForwardConfig, enabled: Boolean, onSaveClicked: (StoreForwardConfig) -> Unit, ) LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigItemList( telemetryConfig: TelemetryConfig, enabled: Boolean, onSaveClicked: (TelemetryConfig) -> Unit, ) - LongMethod:UIState.kt$UIViewModel$fun saveMessagesCSV(uri: Uri) - LongParameterList:BTScanModel.kt$BTScanModel$( private val application: Application, private val serviceRepository: ServiceRepository, private val bluetoothRepository: BluetoothRepository, private val usbRepository: UsbRepository, private val usbManagerLazy: dagger.Lazy<UsbManager>, private val networkRepository: NetworkRepository, private val radioInterfaceService: RadioInterfaceService, ) LongParameterList:NOAAWmsTileSource.kt$NOAAWmsTileSource$( aName: String, aBaseUrl: Array<String>, layername: String, version: String, time: String?, srs: String, style: String?, format: String, ) - LongParameterList:OnlineTileSourceAuth.kt$OnlineTileSourceAuth$( aName: String, aZoomLevel: Int, aZoomMaxLevel: Int, aTileSizePixels: Int, aImageFileNameEnding: String, aBaseUrl: Array<String>, pCopyright: String, tileSourcePolicy: TileSourcePolicy, layerName: String?, apiKey: String ) LongParameterList:RadioInterfaceService.kt$RadioInterfaceService$( private val context: Application, private val dispatchers: CoroutineDispatchers, private val bluetoothRepository: BluetoothRepository, private val networkRepository: NetworkRepository, private val processLifecycle: Lifecycle, @RadioRepositoryQualifier private val prefs: SharedPreferences, private val interfaceFactory: InterfaceFactory, ) MagicNumber:BatteryInfo.kt$100 MagicNumber:BatteryInfo.kt$101 @@ -188,7 +92,6 @@ MagicNumber:BatteryInfo.kt$79 MagicNumber:BatteryInfo.kt$80 MagicNumber:BluetoothInterface.kt$BluetoothInterface$1000 - MagicNumber:BluetoothInterface.kt$BluetoothInterface$1500 MagicNumber:BluetoothInterface.kt$BluetoothInterface$500 MagicNumber:BluetoothInterface.kt$BluetoothInterface$512 MagicNumber:Channel.kt$0xff @@ -219,7 +122,6 @@ MagicNumber:ChannelSet.kt$960 MagicNumber:Contacts.kt$7 MagicNumber:Contacts.kt$8 - MagicNumber:ContextServices.kt$33 MagicNumber:DataPacket.kt$DataPacket.CREATOR$16 MagicNumber:Debug.kt$3 MagicNumber:DeviceVersion.kt$DeviceVersion$100 @@ -234,9 +136,6 @@ MagicNumber:EditListPreference.kt$12 MagicNumber:EditListPreference.kt$12345 MagicNumber:EditListPreference.kt$67890 - MagicNumber:EditWaypointDialog.kt$123 - MagicNumber:EditWaypointDialog.kt$128169 - MagicNumber:EditWaypointDialog.kt$128205 MagicNumber:Extensions.kt$1000 MagicNumber:Extensions.kt$1440000 MagicNumber:Extensions.kt$24 @@ -246,26 +145,14 @@ MagicNumber:LocationRepository.kt$LocationRepository$1000L MagicNumber:LocationRepository.kt$LocationRepository$30 MagicNumber:LocationRepository.kt$LocationRepository$31 - MagicNumber:LocationUtils.kt$0.8 - MagicNumber:LocationUtils.kt$110540 - MagicNumber:LocationUtils.kt$111320 - MagicNumber:LocationUtils.kt$180 MagicNumber:LocationUtils.kt$1e-7 MagicNumber:LocationUtils.kt$360 - MagicNumber:LocationUtils.kt$360.0 - MagicNumber:LocationUtils.kt$3600.0 - MagicNumber:LocationUtils.kt$60 - MagicNumber:LocationUtils.kt$60.0 - MagicNumber:LocationUtils.kt$6366000 - MagicNumber:LocationUtils.kt$GPSFormat$3 MagicNumber:MQTTRepository.kt$MQTTRepository$512 MagicNumber:MapView.kt$0.5f MagicNumber:MapView.kt$1.3 - MagicNumber:MapView.kt$1000 MagicNumber:MapView.kt$1024.0 MagicNumber:MapView.kt$128205 MagicNumber:MapView.kt$12F - MagicNumber:MapView.kt$1e-7 MagicNumber:MapView.kt$<no name provided>$1e7 MagicNumber:MapViewExtensions.kt$1e-5 MagicNumber:MapViewExtensions.kt$1e-7 @@ -287,14 +174,6 @@ MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5 MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7 - MagicNumber:MockInterface.kt$MockInterface$1.5f - MagicNumber:MockInterface.kt$MockInterface$1000 - MagicNumber:MockInterface.kt$MockInterface$16 - MagicNumber:MockInterface.kt$MockInterface$2000 - MagicNumber:MockInterface.kt$MockInterface$32.776665 - MagicNumber:MockInterface.kt$MockInterface$32.960758 - MagicNumber:MockInterface.kt$MockInterface$96.733521 - MagicNumber:MockInterface.kt$MockInterface$96.796989 MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$180 MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$256 MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$360.0 @@ -310,13 +189,11 @@ MagicNumber:NodeInfo.kt$NodeInfo$0xFF0000 MagicNumber:NodeInfo.kt$NodeInfo$1000 MagicNumber:NodeInfo.kt$NodeInfo$1000.0 - MagicNumber:NodeInfo.kt$NodeInfo$15 MagicNumber:NodeInfo.kt$NodeInfo$16 MagicNumber:NodeInfo.kt$NodeInfo$1609 MagicNumber:NodeInfo.kt$NodeInfo$1609.34 MagicNumber:NodeInfo.kt$NodeInfo$255 MagicNumber:NodeInfo.kt$NodeInfo$3.281 - MagicNumber:NodeInfo.kt$NodeInfo$60 MagicNumber:NodeInfo.kt$NodeInfo$8 MagicNumber:NodeInfo.kt$Position$180 MagicNumber:NodeInfo.kt$Position$90 @@ -354,102 +231,23 @@ MaxLineLength:AppPrefs.kt$FloatPref$fun get(thisRef: AppPrefs, prop: KProperty<Float>): Float MaxLineLength:AppPrefs.kt$StringPref$fun get(thisRef: AppPrefs, prop: KProperty<String>): String MaxLineLength:BluetoothInterface.kt$/* Info for the esp32 device side code. See that source for the 'gold' standard docs on this interface. MeshBluetoothService UUID 6ba1b218-15a8-461f-9fa8-5dcae273eafd FIXME - notify vs indication for fromradio output. Using notify for now, not sure if that is best FIXME - in the esp32 mesh management code, occasionally mirror the current net db to flash, so that if we reboot we still have a good guess of users who are out there. FIXME - make sure this protocol is guaranteed robust and won't drop packets "According to the BLE specification the notification length can be max ATT_MTU - 3. The 3 bytes subtracted is the 3-byte header(OP-code (operation, 1 byte) and the attribute handle (2 bytes)). In BLE 4.1 the ATT_MTU is 23 bytes (20 bytes for payload), but in BLE 4.2 the ATT_MTU can be negotiated up to 247 bytes." MAXPACKET is 256? look into what the lora lib uses. FIXME Characteristics: UUID properties description 8ba2bcc2-ee02-4a55-a531-c525c5e454d5 read fromradio - contains a newly received packet destined towards the phone (up to MAXPACKET bytes? per packet). After reading the esp32 will put the next packet in this mailbox. If the FIFO is empty it will put an empty packet in this mailbox. f75c76d2-129e-4dad-a1dd-7866124401e7 write toradio - write ToRadio protobufs to this charstic to send them (up to MAXPACKET len) ed9da18c-a800-4f66-a670-aa7547e34453 read|notify|write fromnum - the current packet # in the message waiting inside fromradio, if the phone sees this notify it should read messages until it catches up with this number. The phone can write to this register to go backwards up to FIXME packets, to handle the rare case of a fromradio packet was dropped after the esp32 callback was called, but before it arrives at the phone. If the phone writes to this register the esp32 will discard older packets and put the next packet >= fromnum in fromradio. When the esp32 advances fromnum, it will delay doing the notify by 100ms, in the hopes that the notify will never actally need to be sent if the phone is already pulling from fromradio. Note: that if the phone ever sees this number decrease, it means the esp32 has rebooted. Re: queue management Not all messages are kept in the fromradio queue (filtered based on SubPacket): * only the most recent Position and User messages for a particular node are kept * all Data SubPackets are kept * No WantNodeNum / DenyNodeNum messages are kept A variable keepAllPackets, if set to true will suppress this behavior and instead keep everything for forwarding to the phone (for debugging) */ - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$* - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$// BLE handles stable. So turn the hack off for these devices. FIXME - find a better way to know that the board is NRF52 based - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$// The following optimization is not currently correct - because the device might be sleeping and come back with different BLE handles - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$/// Our service - note - it is possible to get back a null response for getService if the device services haven't yet been found - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$/// We gracefully handle safe being null because this can occur if someone has unpaired from our device - just abandon the reconnect attempt - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$/// We only force service refresh the _first_ time we connect to the device. Thereafter it is assumed the firmware didn't change - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$//needForceRefresh = false // In fact, because of tearing down BLE in sleep on the ESP32, our handle # assignments are not stable across sleep - so we much refetch every time - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$delay(1000) - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$delay(500) - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$if - MaxLineLength:BluetoothInterface.kt$BluetoothInterface$null MaxLineLength:BluetoothState.kt$BluetoothState$"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map { it.anonymize }})" MaxLineLength:Channel.kt$Channel$// We have a new style 'empty' channel name. Use the same logic from the device to convert that to a human readable name - MaxLineLength:ContextServices.kt$val Context.locationManager: LocationManager get() = requireNotNull(getSystemService(Context.LOCATION_SERVICE) as? LocationManager?) - MaxLineLength:ContextServices.kt$val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?) - MaxLineLength:CustomTileSource.kt$CustomTileSource.Companion$arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?") MaxLineLength:DataPacket.kt$DataPacket$val dataType: Int MaxLineLength:LoRaConfigItemList.kt$value = if (isFocused || loraInput.overrideFrequency != 0f) loraInput.overrideFrequency else primaryChannel.radioFreq MaxLineLength:LocationRepository.kt$LocationRepository$info("Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m") MaxLineLength:MQTTRepository.kt$MQTTRepository.Companion$* - MaxLineLength:MainActivity.kt$MainActivity$/* This problem can occur if we unbind, but there is already an onConnected job waiting to run. That job runs and then makes meshService != null again I think I've fixed this by cancelling connectionJob. We'll see! */ - MaxLineLength:MainActivity.kt$MainActivity$// Old samsung phones have a race condition andthis might rarely fail. Which is probably find because the bind will be sufficient most of the time - MaxLineLength:MeshService.kt$MeshService$* - MaxLineLength:MeshService.kt$MeshService$* Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException - MaxLineLength:MeshService.kt$MeshService$// If we've received our initial config, our radio settings and all of our channels, send any queued packets and broadcast connected to clients - MaxLineLength:MeshService.kt$MeshService$// Nodes periodically send out position updates, but those updates might not contain a lat & lon (because no GPS lock) - MaxLineLength:MeshService.kt$MeshService$// Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than this possibly stale hint) - MaxLineLength:MeshService.kt$MeshService$// Update last seen for the node that sent the packet, but also for _our node_ because anytime a packet passes - MaxLineLength:MeshService.kt$MeshService$// Update our last seen based on any valid timestamps. If the device didn't provide a timestamp make one - MaxLineLength:MeshService.kt$MeshService$// We always start foreground because that's how our service is always started (if we didn't then android would kill us) - MaxLineLength:MeshService.kt$MeshService$// We like to look at the local node to see if it has been sending out valid lat/lon, so for the LOCAL node (only) - MaxLineLength:MeshService.kt$MeshService$// We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a node, we can also find it based on node number - MaxLineLength:MeshService.kt$MeshService$// because apps really only care about important updates of node state - which handledReceivedData will give them - MaxLineLength:MeshService.kt$MeshService$// causes the phone to try and reconnect. If we fail downloading our initial radio state we don't want to - MaxLineLength:MeshService.kt$MeshService$// logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, but if the device is messed up it might try to send forever - MaxLineLength:MeshService.kt$MeshService$// note: no need to call startDeviceSleep(), because this exception could only have reached us if it was already called - MaxLineLength:MeshService.kt$MeshService$MeshProtos.FromRadio.MQTTCLIENTPROXYMESSAGE_FIELD_NUMBER -> handleMqttProxyMessage(proto.mqttClientProxyMessage) - MaxLineLength:MeshService.kt$MeshService$debug("Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}") - MaxLineLength:MeshService.kt$MeshService.Companion$// generate a RECEIVED action filter string that includes either the portnumber as an int, or preferably a symbolic name from portnums.proto - MaxLineLength:MeshServiceBroadcasts.kt$MeshServiceBroadcasts$context.sendBroadcast(intent) - MaxLineLength:MeshServiceNotifications.kt$MeshServiceNotifications$// If running on really old versions of android (<= 5.1.1) (possibly only cyanogen) we might encounter a bug with setting application specific icons - MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"") - MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"") - MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 -> "%.0f ft".format(dist.toDouble()*3.281) - MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 -> "%.1f mi".format(dist / 1609.34) - MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 -> "%.0f m".format(dist.toDouble()) - MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 -> "%.1f km".format(dist / 1000.0) - MaxLineLength:NodeInfo.kt$Position$/** - MaxLineLength:NodeInfo.kt$Position$return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})" - MaxLineLength:PositionConfigItemList.kt$. - MaxLineLength:RadioInterfaceService.kt$RadioInterfaceService$/** - MaxLineLength:RadioInterfaceService.kt$RadioInterfaceService$// If we are running on the emulator we default to the mock interface, so we can have some data to show to the user - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$* - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$* mtu operations seem to hang sometimes. To cope with this we have a 5 second timeout before throwing an exception and cancelling the work - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$// Attempt to invoke virtual method 'com.android.bluetooth.gatt.AdvertiseClient com.android.bluetooth.gatt.AdvertiseManager.getAdvertiseClient(int)' on a null object reference - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$// Set these to null _before_ calling gatt.disconnect(), because we don't want the old lostConnectCallback to get called - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$// We might unexpectedly fail inside here, but we don't want to pass that exception back up to the bluetooth GATT layer - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$// note - we don't need an init fn (because that would normally redo the connectGatt call - which we don't need) - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$06-29 08:47:15.037 29788-29813/com.geeksville.mesh D/BluetoothGatt: onClientConnectionState() - status=0 clientIf=5 device=24:62:AB:F8:40:9A - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$?: - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$if - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$if (enable) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE - MaxLineLength:SafeBluetooth.kt$SafeBluetooth$java.lang.NullPointerException: Attempt to invoke virtual method 'void android.bluetooth.BluetoothGattCallback.onConnectionStateChange(android.bluetooth.BluetoothGatt, int, int)' on a null object reference - MaxLineLength:SafeBluetooth.kt$SafeBluetooth.<no name provided>$// After this execute reliable completes - we can continue with normal operations (see onReliableWriteCompleted) - MaxLineLength:SafeBluetooth.kt$SafeBluetooth.<no name provided>$// Note: if no work is pending (likely) we also just totally teardown and restart the connection, because we won't be - MaxLineLength:SafeBluetooth.kt$SafeBluetooth.<no name provided>$// We were not previously connected and we just failed with our non-auto connection attempt. Therefore we now need - MaxLineLength:SafeBluetooth.kt$SafeBluetooth.<no name provided>$// to do an autoconnection attempt. When that attempt succeeds/fails the normal callbacks will be called MaxLineLength:ServiceClient.kt$ServiceClient$// Some phones seem to ahve a race where if you unbind and quickly rebind bindService returns false. Try MaxLineLength:ServiceClient.kt$ServiceClient.<no name provided>$// If we start to close a service, it seems that there is a possibility a onServiceConnected event is the queue - MaxLineLength:SqlTileWriterExt.kt$SqlTileWriterExt$"select " + DatabaseFileArchive.COLUMN_KEY + "," + COLUMN_EXPIRES + "," + DatabaseFileArchive.COLUMN_PROVIDER + " from " + DatabaseFileArchive.TABLE + " limit ? offset ?" MaxLineLength:StreamInterface.kt$StreamInterface$* MaxLineLength:StreamInterface.kt$StreamInterface$* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP probably) MaxLineLength:StreamInterface.kt$StreamInterface$// Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this code will be run with ptr of4 MaxLineLength:StreamInterface.kt$StreamInterface$deliverPacket() MaxLineLength:StreamInterface.kt$StreamInterface$lostSync() MaxLineLength:StreamInterface.kt$StreamInterface$service.onDisconnect(isPermanent = true) - MaxLineLength:UIState.kt$UIViewModel$// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload - MaxLineLength:UIState.kt$UIViewModel$writer.appendLine("$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"") - MaxLineLength:UIState.kt$UIViewModel$writer.appendLine("\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"") MayBeConst:AppPrefs.kt$AppPrefs.Companion$private val baseName = "appPrefs_" - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$doDiscoverServicesAndInit() - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$s.asyncDiscoverServices { discRes -> try { discRes.getOrThrow() service.serviceScope.handledLaunch { try { debug("Discovered services!") delay(1000) // android BLE is buggy and needs a 500ms sleep before calling getChracteristic, or you might get back null /* if (isFirstTime) { isFirstTime = false throw BLEException("Faking a BLE failure") } */ fromNum = getCharacteristic(BTM_FROMNUM_CHARACTER) // We treat the first send by a client as special isFirstSend = true // Now tell clients they can (finally use the api) service.onConnect() // Immediately broadcast any queued packets sitting on the device doReadFromRadio(true) } catch (ex: BLEException) { scheduleReconnect( "Unexpected error in initial device enumeration, forcing disconnect $ex" ) } } } catch (ex: BLEException) { if (s.gatt == null) warn("GATT was closed while discovering, assume we are shutting down") else scheduleReconnect( "Unexpected error discovering services, forcing disconnect $ex" ) } } - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$safe?.asyncRequestMtu(512) { mtuRes -> try { mtuRes.getOrThrow() debug("MTU change attempted") // throw BLEException("Test MTU set failed") doDiscoverServicesAndInit() } catch (ex: BLEException) { shouldSetMtu = false scheduleReconnect( "Giving up on setting MTUs, forcing disconnect $ex" ) } } - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$scheduleReconnect( "Unexpected error discovering services, forcing disconnect $ex" ) - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$startConnect() - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$startWatchingFromNum() - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$warn("GATT was closed while discovering, assume we are shutting down") - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$warn("Interface is shutting down, so skipping discover") - MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$warn("Not connecting, because safe==null, someone must have closed us") - MultiLineIfElse:BluetoothRepository.kt$BluetoothRepository$bondedDevices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true } - MultiLineIfElse:BluetoothRepository.kt$BluetoothRepository$emptyList() MultiLineIfElse:Channel.kt$Channel$"Custom" MultiLineIfElse:Channel.kt$Channel$when (loraConfig.modemPreset) { ModemPreset.SHORT_TURBO -> "ShortTurbo" ModemPreset.SHORT_FAST -> "ShortFast" ModemPreset.SHORT_SLOW -> "ShortSlow" ModemPreset.MEDIUM_FAST -> "MediumFast" ModemPreset.MEDIUM_SLOW -> "MediumSlow" ModemPreset.LONG_FAST -> "LongFast" ModemPreset.LONG_SLOW -> "LongSlow" ModemPreset.LONG_MODERATE -> "LongMod" ModemPreset.VERY_LONG_SLOW -> "VLongSlow" else -> "Invalid" } - MultiLineIfElse:ChannelOption.kt$when (bandwidth) { 31 -> .03125f 62 -> .0625f 200 -> .203125f 400 -> .40625f 800 -> .8125f 1600 -> 1.6250f else -> bandwidth / 1000f } - MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -> } .setPositiveButton(R.string.accept) { _, _ -> invokeFun() } .show() - MultiLineIfElse:ContextServices.kt$invokeFun() MultiLineIfElse:EditListPreference.kt$EditBase64Preference( title = "${index + 1}/$maxCount", value = value, enabled = enabled, keyboardActions = keyboardActions, onValueChange = { listState[index] = it as T onValuesChanged(listState) }, modifier = modifier.fillMaxWidth(), trailingIcon = trailingIcon, ) MultiLineIfElse:EditListPreference.kt$EditTextPreference( title = "${index + 1}/$maxCount", value = value, enabled = enabled, keyboardActions = keyboardActions, onValueChanged = { listState[index] = it as T onValuesChanged(listState) }, modifier = modifier.fillMaxWidth(), trailingIcon = trailingIcon, ) MultiLineIfElse:EditTextPreference.kt$it.toDoubleOrNull()?.let { double -> valueState = it onValueChanged(double) } @@ -458,126 +256,62 @@ MultiLineIfElse:EditTextPreference.kt$onValueChanged(it) MultiLineIfElse:EditTextPreference.kt$valueState = it MultiLineIfElse:Exceptions.kt$Exceptions.errormsg("ignoring exception", ex) - MultiLineIfElse:ExpireChecker.kt$ExpireChecker$doExpire() - MultiLineIfElse:Logging.kt$Logging$printlog(Log.ERROR, tag(), "$msg (exception ${ex.message})") - MultiLineIfElse:Logging.kt$Logging$printlog(Log.ERROR, tag(), "$msg") - MultiLineIfElse:MapViewWithLifecycle.kt$try { acquire() } catch (e: SecurityException) { errormsg("WakeLock permission exception: ${e.message}") } catch (e: IllegalStateException) { errormsg("WakeLock acquire() exception: ${e.message}") } - MultiLineIfElse:MapViewWithLifecycle.kt$try { release() } catch (e: IllegalStateException) { errormsg("WakeLock release() exception: ${e.message}") } - MultiLineIfElse:MeshService.kt$MeshService$getDataPacketById(packetId)?.let { p -> if (p.status == m) return@handledLaunch packetRepository.get().updateMessageStatus(p, m) serviceBroadcasts.broadcastMessageStatus(packetId, m) } - MultiLineIfElse:MeshService.kt$MeshService.<no name provided>$try { sendNow(p) } catch (ex: Exception) { errormsg("Error sending message, so enqueueing", ex) enqueueForSending(p) } - MultiLineIfElse:NOAAWmsTileSource.kt$NOAAWmsTileSource$sb.append("service=WMS") - MultiLineIfElse:NodeInfo.kt$MeshUser$hwModel.name.replace('_', '-').replace('p', '.').lowercase() - MultiLineIfElse:NodeInfo.kt$MeshUser$null - MultiLineIfElse:RadioConfigViewModel.kt$RadioConfigViewModel$viewModelScope.launch { radioConfigRepository.replaceAllSettings(new) } - MultiLineIfElse:RadioInterfaceService.kt$RadioInterfaceService$startInterface() - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$cb - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$null - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$startNewWork() - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$throw AssertionError("currentWork was not null: $currentWork") - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$warn("wor completed, but we already killed it via failsafetimer? status=$status, res=$res") - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$work.completion.resume(Result.success(res) as Result<Nothing>) - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$work.completion.resumeWithException( BLEStatusException( status, "Bluetooth status=$status while doing ${work.tag}" ) ) - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$completeWork(status, Unit) - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$completeWork(status, characteristic) - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$dropAndReconnect() - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$errormsg("Ignoring bogus onMtuChanged") - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$if (!characteristic.value.contentEquals(reliable)) { errormsg("A reliable write failed!") gatt.abortReliableWrite() completeWork( STATUS_RELIABLE_WRITE_FAILED, characteristic ) // skanky code to indicate failure } else { logAssert(gatt.executeReliableWrite()) // After this execute reliable completes - we can continue with normal operations (see onReliableWriteCompleted) } - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$lostConnection("lost connection") - MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$warn("Received notification from $characteristic, but no handler registered") NestedBlockDepth:LanguageUtils.kt$LanguageUtils$fun getLanguageTags(context: Context): Map<String, String> - NestedBlockDepth:MainActivity.kt$MainActivity$private fun onMeshConnectionChanged(newConnection: MeshService.ConnectionState) NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) NestedBlockDepth:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) - NewLineAtEndOfFile:AppIntroduction.kt$com.geeksville.mesh.AppIntroduction.kt NewLineAtEndOfFile:AppPrefs.kt$com.geeksville.mesh.android.AppPrefs.kt NewLineAtEndOfFile:ApplicationModule.kt$com.geeksville.mesh.ApplicationModule.kt NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt NewLineAtEndOfFile:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt NewLineAtEndOfFile:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt - NewLineAtEndOfFile:BluetoothViewModel.kt$com.geeksville.mesh.model.BluetoothViewModel.kt NewLineAtEndOfFile:BootCompleteReceiver.kt$com.geeksville.mesh.service.BootCompleteReceiver.kt NewLineAtEndOfFile:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt - NewLineAtEndOfFile:CustomTileSource.kt$com.geeksville.mesh.model.map.CustomTileSource.kt NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt NewLineAtEndOfFile:DeferredExecution.kt$com.geeksville.mesh.concurrent.DeferredExecution.kt NewLineAtEndOfFile:DeviceVersion.kt$com.geeksville.mesh.model.DeviceVersion.kt - NewLineAtEndOfFile:DeviceVersionTest.kt$com.geeksville.mesh.model.DeviceVersionTest.kt - NewLineAtEndOfFile:ExpireChecker.kt$com.geeksville.mesh.android.ExpireChecker.kt NewLineAtEndOfFile:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt NewLineAtEndOfFile:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt - NewLineAtEndOfFile:Logging.kt$com.geeksville.mesh.android.Logging.kt NewLineAtEndOfFile:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt - NewLineAtEndOfFile:NOAAWmsTileSource.kt$com.geeksville.mesh.model.map.NOAAWmsTileSource.kt NewLineAtEndOfFile:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt NewLineAtEndOfFile:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt - NewLineAtEndOfFile:OnlineTileSourceAuth.kt$com.geeksville.mesh.model.map.OnlineTileSourceAuth.kt NewLineAtEndOfFile:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt NewLineAtEndOfFile:QuickChatActionRepository.kt$com.geeksville.mesh.database.QuickChatActionRepository.kt NewLineAtEndOfFile:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt NewLineAtEndOfFile:RadioRepositoryModule.kt$com.geeksville.mesh.repository.radio.RadioRepositoryModule.kt NewLineAtEndOfFile:RegularPreference.kt$com.geeksville.mesh.ui.common.components.RegularPreference.kt - NewLineAtEndOfFile:SafeBluetooth.kt$com.geeksville.mesh.service.SafeBluetooth.kt NewLineAtEndOfFile:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt NewLineAtEndOfFile:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt NewLineAtEndOfFile:SerialInterface.kt$com.geeksville.mesh.repository.radio.SerialInterface.kt NewLineAtEndOfFile:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt - NewLineAtEndOfFile:SqlTileWriterExt.kt$com.geeksville.mesh.util.SqlTileWriterExt.kt NewLineAtEndOfFile:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt NewLineAtEndOfFile:UsbBroadcastReceiver.kt$com.geeksville.mesh.repository.usb.UsbBroadcastReceiver.kt NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt - NoBlankLineBeforeRbrace:BluetoothInterface.kt$BluetoothInterface$ - NoBlankLineBeforeRbrace:CustomTileSource.kt$CustomTileSource$ NoBlankLineBeforeRbrace:DebugLogFile.kt$BinaryLogFile$ - NoBlankLineBeforeRbrace:NOAAWmsTileSource.kt$NOAAWmsTileSource$ NoBlankLineBeforeRbrace:NopInterface.kt$NopInterface$ - NoBlankLineBeforeRbrace:OnlineTileSourceAuth.kt$OnlineTileSourceAuth$ - NoBlankLineBeforeRbrace:PositionTest.kt$PositionTest$ - NoConsecutiveBlankLines:AppIntroduction.kt$AppIntroduction$ NoConsecutiveBlankLines:AppPrefs.kt$ - NoConsecutiveBlankLines:BluetoothInterface.kt$ - NoConsecutiveBlankLines:BluetoothInterface.kt$BluetoothInterface$ NoConsecutiveBlankLines:BootCompleteReceiver.kt$ NoConsecutiveBlankLines:Constants.kt$ - NoConsecutiveBlankLines:CustomTileSource.kt$ - NoConsecutiveBlankLines:CustomTileSource.kt$CustomTileSource.Companion$ NoConsecutiveBlankLines:DebugLogFile.kt$ NoConsecutiveBlankLines:DeferredExecution.kt$ NoConsecutiveBlankLines:Exceptions.kt$ NoConsecutiveBlankLines:IRadioInterface.kt$ - NoConsecutiveBlankLines:NOAAWmsTileSource.kt$NOAAWmsTileSource$ - NoConsecutiveBlankLines:NodeInfo.kt$ - NoConsecutiveBlankLines:SafeBluetooth.kt$ - NoConsecutiveBlankLines:SafeBluetooth.kt$SafeBluetooth$ - NoConsecutiveBlankLines:SqlTileWriterExt.kt$ NoEmptyClassBody:DebugLogFile.kt$BinaryLogFile${ } NoSemicolons:DateUtils.kt$DateUtils$; - NoTrailingSpaces:ExpireChecker.kt$ExpireChecker$ - NoWildcardImports:BluetoothInterface.kt$import com.geeksville.mesh.service.* - NoWildcardImports:DeviceVersionTest.kt$import org.junit.Assert.* NoWildcardImports:MockInterface.kt$import com.geeksville.mesh.* - NoWildcardImports:SafeBluetooth.kt$import android.bluetooth.* - NoWildcardImports:SafeBluetooth.kt$import kotlinx.coroutines.* NoWildcardImports:UsbRepository.kt$import kotlinx.coroutines.flow.* OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract ParameterListWrapping:AppPrefs.kt$FloatPref$(thisRef: AppPrefs, prop: KProperty<Float>) ParameterListWrapping:AppPrefs.kt$StringPref$(thisRef: AppPrefs, prop: KProperty<String>) - ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, cont: Continuation<BluetoothGattCharacteristic>, timeout: Long = 0 ) - ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, cont: Continuation<Unit>, timeout: Long = 0 ) - ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, v: ByteArray, cont: Continuation<BluetoothGattCharacteristic>, timeout: Long = 0 ) - ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattDescriptor, cont: Continuation<BluetoothGattDescriptor>, timeout: Long = 0 ) RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex - ReturnCount:ChannelOption.kt$internal fun LoRaConfig.radioFreq(channelNum: Int): Float ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) SpacingAroundCurly:AppPrefs.kt$FloatPref$} SpacingAroundKeyword:AppPrefs.kt$AppPrefs$if SpacingAroundKeyword:Exceptions.kt$if SpacingAroundKeyword:Exceptions.kt$when - SpacingAroundOperators:NodeInfo.kt$NodeInfo$* SpacingAroundRangeOperator:BatteryInfo.kt$.. - StringTemplate:NodeInfo.kt$Position$${time} SwallowedException:BluetoothInterface.kt$BluetoothInterface$ex: CancellationException SwallowedException:ChannelSet.kt$ex: Throwable SwallowedException:DeviceVersion.kt$DeviceVersion$e: Exception @@ -587,7 +321,6 @@ SwallowedException:MeshService.kt$MeshService$e: TimeoutException SwallowedException:MeshService.kt$MeshService$ex: BLEException SwallowedException:MeshService.kt$MeshService$ex: CancellationException - SwallowedException:MeshService.kt$MeshService$ex: RadioNotConnectedException SwallowedException:NsdManager.kt$ex: IllegalArgumentException SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: DeadObjectException SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: NullPointerException @@ -595,7 +328,6 @@ SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable TooGenericExceptionCaught:BluetoothInterface.kt$BluetoothInterface$ex: Exception - TooGenericExceptionCaught:Channel.kt$ex: Exception TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable TooGenericExceptionCaught:DeviceVersion.kt$DeviceVersion$e: Exception TooGenericExceptionCaught:Exceptions.kt$ex: Throwable @@ -603,8 +335,8 @@ TooGenericExceptionCaught:LocationRepository.kt$LocationRepository$e: Exception TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception TooGenericExceptionCaught:MainActivity.kt$MainActivity$ex: Exception - TooGenericExceptionCaught:MainActivity.kt$MainActivity$ex: Throwable TooGenericExceptionCaught:MapView.kt$ex: Exception + TooGenericExceptionCaught:MapViewModel.kt$MapViewModel$e: Exception TooGenericExceptionCaught:MeshService.kt$MeshService$e: Exception TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception TooGenericExceptionCaught:MeshService.kt$MeshService.<no name provided>$ex: Exception @@ -624,9 +356,6 @@ TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen") TooManyFunctions:AppPrefs.kt$AppPrefs TooManyFunctions:BluetoothInterface.kt$BluetoothInterface : IRadioInterfaceLogging - TooManyFunctions:ContactSharing.kt$com.geeksville.mesh.ui.sharing.ContactSharing.kt - TooManyFunctions:ContextServices.kt$com.geeksville.mesh.android.ContextServices.kt - TooManyFunctions:LocationUtils.kt$com.geeksville.mesh.util.LocationUtils.kt TooManyFunctions:MainActivity.kt$MainActivity : AppCompatActivityLogging TooManyFunctions:MeshService.kt$MeshService : ServiceLogging TooManyFunctions:MeshService.kt$MeshService$<no name provided> : Stub @@ -641,31 +370,9 @@ TopLevelPropertyNaming:Constants.kt$const val prefix = "com.geeksville.mesh" UnusedPrivateMember:NOAAWmsTileSource.kt$NOAAWmsTileSource$private fun tile2lat(y: Int, z: Int): Double UnusedPrivateMember:NOAAWmsTileSource.kt$NOAAWmsTileSource$private fun tile2lon(x: Int, z: Int): Double - UnusedPrivateMember:SafeBluetooth.kt$SafeBluetooth$private fun reconnect() - UnusedPrivateProperty:BluetoothInterface.kt$BluetoothInterface$/// For testing @Volatile private var isFirstTime = true - UnusedPrivateProperty:BluetoothInterface.kt$BluetoothInterface$/// We only force service refresh the _first_ time we connect to the device. Thereafter it is assumed the firmware didn't change private var hasForcedRefresh = false - UnusedPrivateProperty:CustomTileSource.kt$CustomTileSource.Companion$private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP - UnusedPrivateProperty:CustomTileSource.kt$CustomTileSource.Companion$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) } } - UnusedPrivateProperty:CustomTileSource.kt$CustomTileSource.Companion$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) } } UtilityClassWithPublicConstructor:CustomTileSource.kt$CustomTileSource UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$// Size of square world map in meters, using WebMerc projection. private val MAP_SIZE = 20037508.34789244 * 2 - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$// Web Mercator n/w corner of the map. private val TILE_ORIGIN = doubleArrayOf(-20037508.34789244, 20037508.34789244) - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$// array indexes for array to hold bounding boxes. private val MINX = 0 - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$//array indexes for that data private val ORIG_X = 0 - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val MAXX = 1 - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val MAXY = 3 - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val MINY = 2 - VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val ORIG_Y = 1 // " - VariableNaming:SafeBluetooth.kt$SafeBluetooth$// Our own custom BLE status codes private val STATUS_RELIABLE_WRITE_FAILED = 4403 - VariableNaming:SafeBluetooth.kt$SafeBluetooth$private val STATUS_NOSTART = 4405 - VariableNaming:SafeBluetooth.kt$SafeBluetooth$private val STATUS_SIMFAILURE = 4406 - VariableNaming:SafeBluetooth.kt$SafeBluetooth$private val STATUS_TIMEOUT = 4404 - WildcardImport:BluetoothInterface.kt$import com.geeksville.mesh.service.* - WildcardImport:DeviceVersionTest.kt$import org.junit.Assert.* WildcardImport:MockInterface.kt$import com.geeksville.mesh.* - WildcardImport:SafeBluetooth.kt$import android.bluetooth.* - WildcardImport:SafeBluetooth.kt$import kotlinx.coroutines.* WildcardImport:UsbRepository.kt$import kotlinx.coroutines.flow.* diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76e06c9f8..6e1aa8325 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,8 @@ kotlinx-collections-immutable = "0.4.0" kotlinx-coroutines-android = "1.10.2" kotlinx-serialization-json = "1.9.0" lifecycle = "2.9.2" +location-services = "21.3.0" +maps-compose = "6.7.1" markdownRenderer = "0.35.0" material = "1.12.0" material3 = "1.5.0-alpha01" @@ -126,6 +128,10 @@ lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-ru lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location-services" } +maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "maps-compose" } +maps-compose-utils = { group = "com.google.maps.android", name = "maps-compose-utils", version.ref = "maps-compose" } +maps-compose-widgets = { group = "com.google.maps.android", name = "maps-compose-widgets", version.ref = "maps-compose" } markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" } markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } markdown-renderer-android = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" } @@ -193,6 +199,9 @@ testing-room = ["room-testing"] # OSM osm = ["osmdroid-android", "osmbonuspack", "mgrs"] +# Google Maps (Compose) +maps-compose = ["location-services", "maps-compose", "maps-compose-utils", "maps-compose-widgets"] + # Firebase firebase = ["firebase-analytics", "firebase-crashlytics"] @@ -210,6 +219,7 @@ coil = ["coil", "coil-network-core", "coil-network-okhttp", "coil-svg"] [plugins] android-application = { id = "com.android.application" } +android-library = { id = "com.android.library" } compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } datadog = { id = "com.datadoghq.dd-sdk-android-gradle-plugin"} detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } @@ -220,8 +230,8 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization" } protobuf = { id = "com.google.protobuf" } -android-library = { id = "com.android.library" } google-services = { id = "com.google.gms.google-services" } firebase-crashlytics = { id = "com.google.firebase.crashlytics" } secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin"} spotless = { id = "com.diffplug.spotless", version .ref= "spotless" } +secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin" } diff --git a/mesh_service_example/src/main/java/com/geeksville/mesh/NodeInfo.kt b/mesh_service_example/src/main/java/com/geeksville/mesh/NodeInfo.kt index f1570782e..73ebdae14 100644 --- a/mesh_service_example/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/mesh_service_example/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.bearing import com.geeksville.mesh.util.latLongToMeter import com.geeksville.mesh.util.anonymize @@ -115,14 +114,6 @@ data class Position( (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 { return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})" } diff --git a/network/build.gradle.kts b/network/build.gradle.kts index e82f13a16..d35cae064 100644 --- a/network/build.gradle.kts +++ b/network/build.gradle.kts @@ -15,6 +15,7 @@ * along with this program. If not, see . */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) @@ -31,7 +32,7 @@ android { } compileSdk = Configs.COMPILE_SDK defaultConfig { - minSdk = Configs.MIN_SDK_VERSION + minSdk = Configs.MIN_SDK } namespace = "com.geeksville.mesh.network" @@ -64,4 +65,11 @@ dependencies { detekt { config.setFrom("../config/detekt/detekt.yml") baseline = file("../config/detekt/detekt-baseline-network.xml") + source.setFrom( + files( + "src/main/java", + "google/main/java", + "fdroid/main/java", + ) + ) } diff --git a/secrets.defaults.properties b/secrets.defaults.properties index 419b53183..66026ce19 100644 --- a/secrets.defaults.properties +++ b/secrets.defaults.properties @@ -21,5 +21,6 @@ # Replace these with actual keys when building the app to enable datadog reporting datadogClientToken=faketoken1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef datadogApplicationId=fakeappid1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef +MAPS_API_KEY=DEFAULT_API_KEY