refactor: maps (#2097)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-13 12:51:19 -05:00 committed by GitHub
parent c05f434ff2
commit 87e50e03ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 4188 additions and 1830 deletions

View file

@ -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 }}

View file

@ -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:

2
.gitignore vendored
View file

@ -29,4 +29,6 @@ keystore.properties
# VS code
.vscode/settings.json
# Secrets
/secrets.properties

View file

@ -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.

View file

@ -15,6 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Detekt> {
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 {

View file

@ -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() {}
}

View file

@ -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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ITileSource, String> =
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")
}
}
}

View file

@ -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<Int, Int>) {
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)
}
}

View file

@ -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"
}
}
}

View file

@ -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<String>,
name: String,
zoomLevel: Int,
zoomMaxLevel: Int,
tileSizePixels: Int,
imageFileNameEnding: String,
baseUrl: Array<String>,
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"
}
}
override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getX(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex).toString()
) +
mImageFilenameEnding +
"?appId=$apiKey"
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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;

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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;

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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;

View file

@ -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<Node>): List<MarkerWithLabel> {
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<CharSequence> = 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
},
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}

View file

@ -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

View file

@ -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 = {})
}

View file

@ -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 = {})
//}
// }

View file

@ -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 = {},
)
}
}

View file

@ -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) }
}

View file

@ -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) {}
}
},
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}

View file

@ -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<GeoPoint>,
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<GeoPoint>, 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<MeshProtos.Position>,
onClick: () -> Unit
): List<Marker> {
fun MapView.addPositionMarkers(positions: List<MeshProtos.Position>, onClick: () -> Unit): List<Marker> {
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

View file

@ -21,25 +21,28 @@ import android.database.Cursor
import org.osmdroid.tileprovider.modules.DatabaseFileArchive
import org.osmdroid.tileprovider.modules.SqlTileWriter
/**
* Extended the sqlite tile writer to have some additional query functions. A this point
* it's unclear if there is a need to put these with the osmdroid-android library, thus they were
* put here as more of an example.
*
* Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need
* to put these with the osmdroid-android library, thus they were put here as more of an example.
*
* created on 12/21/2016.
*
* @author Alex O'Ree
* @since 5.6.2
*/
class SqlTileWriterExt() : SqlTileWriter() {
fun select(rows: Int, offset: Int): Cursor? {
return this.db?.rawQuery(
"select " + DatabaseFileArchive.COLUMN_KEY + "," + COLUMN_EXPIRES + "," + DatabaseFileArchive.COLUMN_PROVIDER + " from " + DatabaseFileArchive.TABLE + " limit ? offset ?",
arrayOf(rows.toString() + "", offset.toString() + "")
)
}
class SqlTileWriterExt : SqlTileWriter() {
fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery(
"select " +
DatabaseFileArchive.COLUMN_KEY +
"," +
COLUMN_EXPIRES +
"," +
DatabaseFileArchive.COLUMN_PROVIDER +
" from " +
DatabaseFileArchive.TABLE +
" limit ? offset ?",
arrayOf(rows.toString() + "", offset.toString() + ""),
)
/**
* gets all the tiles sources that we have tiles for in the cache database and their counts
@ -55,16 +58,27 @@ class SqlTileWriterExt() : SqlTileWriter() {
}
var cur: Cursor? = null
try {
cur = db.rawQuery(
"select "
+ DatabaseFileArchive.COLUMN_PROVIDER
+ ",count(*) "
+ ",min(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
+ ",max(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
+ ",sum(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
+ "from " + DatabaseFileArchive.TABLE + " "
+ "group by " + DatabaseFileArchive.COLUMN_PROVIDER, null
)
cur =
db.rawQuery(
"select " +
DatabaseFileArchive.COLUMN_PROVIDER +
",count(*) " +
",min(length(" +
DatabaseFileArchive.COLUMN_TILE +
")) " +
",max(length(" +
DatabaseFileArchive.COLUMN_TILE +
")) " +
",sum(length(" +
DatabaseFileArchive.COLUMN_TILE +
")) " +
"from " +
DatabaseFileArchive.TABLE +
" " +
"group by " +
DatabaseFileArchive.COLUMN_PROVIDER,
null,
)
while (cur.moveToNext()) {
val c = SourceCount()
c.source = cur.getString(0)
@ -82,12 +96,11 @@ class SqlTileWriterExt() : SqlTileWriter() {
}
return ret
}
val rowCountExpired: Long
get() = getRowCount(
"$COLUMN_EXPIRES<?", arrayOf(System.currentTimeMillis().toString())
)
class SourceCount() {
val rowCountExpired: Long
get() = getRowCount("$COLUMN_EXPIRES<?", arrayOf(System.currentTimeMillis().toString()))
class SourceCount {
var rowCount: Long = 0
var source: String? = null
var sizeTotal: Long = 0
@ -95,4 +108,4 @@ class SqlTileWriterExt() : SqlTileWriter() {
var sizeMax: Long = 0
var sizeAvg: Long = 0
}
}
}

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- hardware acceleration is required for zxing barcode lib -->
<application
android:name="com.geeksville.mesh.MeshUtilApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher2"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher2_round"
android:supportsRtl="true"
android:hardwareAccelerated="true"
android:theme="@style/AppTheme"
android:localeConfig="@xml/locales_config">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
</application>
</manifest>

View file

@ -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

View file

@ -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) {

View file

@ -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)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.model.map
class CustomTileSource {
companion object {
fun getTileSource(index: Int) {
index
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.map
import com.geeksville.mesh.ui.map.CustomTileProviderConfig
import kotlinx.coroutines.flow.Flow
interface CustomTileProviderRepository {
fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
suspend fun deleteCustomTileProvider(configId: String)
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<CustomTileProviderConfig>>(emptyList())
init {
loadDataFromPrefs()
}
private fun loadDataFromPrefs() {
val jsonString = sharedPreferences.getString(KEY_CUSTOM_TILE_PROVIDERS, null)
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(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<CustomTileProviderConfig>) {
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<List<CustomTileProviderConfig>> =
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 }
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<Position>?): List<Position> {
if (nodeTrack.isNullOrEmpty()) return emptyList()
val sortedTrack = nodeTrack.sortedBy { it.time }
if (sortedTrack.size <= 2) return sortedTrack.map { it }
val filteredPoints = mutableListOf<MeshProtos.Position>()
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<Position>? = 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<Waypoint?>(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<LatLng> =
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<List<NodeClusterItem>?>(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)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>()
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
customTileProviderRepository
.getCustomTileProviders()
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = emptyList())
private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
private val _selectedGoogleMapType = MutableStateFlow<MapType>(MapType.NORMAL)
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
private val _cameraPosition = MutableStateFlow<MapCameraPosition?>(null)
val cameraPosition: StateFlow<MapCameraPosition?> = _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<List<MapLayerItem>>(emptyList())
val mapLayers: StateFlow<List<MapLayerItem>> = _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,
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<NodeClusterItem>,
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
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MapLayerItem>,
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))
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<CustomTileProviderConfig?>(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<String?>(null) }
var urlError by remember { mutableStateOf<String?>(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<CustomTileProviderConfig>,
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
},
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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() },
)
},
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
},
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<NodeClusterItem>,
mapFilterState: BaseMapViewModel.MapFilterState,
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> 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) {}
},
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MeshProtos.Waypoint>,
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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 = {})
}

View file

@ -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)"
}

View file

@ -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,
)

View file

@ -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<Int, Int>? = 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>(LocalConfig.getDefaultInstance())
val localConfig: StateFlow<LocalConfig> = _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<Boolean> = _showIgnored
@ -351,6 +341,7 @@ constructor(
private val _hasShownNotPairedWarning =
MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false))
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
fun suppressNoPairedWarning() {
@ -358,40 +349,22 @@ constructor(
preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) }
}
private fun toggleBooleanPreference(
state: MutableStateFlow<Boolean>,
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<MapFilterState> =
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<MyNodeEntity?>
get() = nodeDB.myNodeInfo
@ -497,22 +453,15 @@ constructor(
val ourNodeInfo: StateFlow<Node?>
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<Int, MeshProtos.Position?>()
@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\"",

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ITileSource, String> = 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")
}
}
}

View file

@ -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<MapRoutes.Map> {
MapView(
model = uiViewModel,
navigateToNodeDetails = {
navController.navigate(NodesRoutes.NodeDetailGraph(it))
},
uiViewModel = uiViewModel,
mapViewModel = mapViewModel,
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
}
}

View file

@ -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<Route.DebugPanel> { DebugScreen() }

View file

@ -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<NodesRoutes.NodesGraph>(
startDestination = NodesRoutes.Nodes,
) {
fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UIViewModel) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes> {
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<NodesRoutes.NodeDetailGraph>(
startDestination = NodesRoutes.NodeDetail(),
) {
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewModel: UIViewModel) {
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
composable<NodesRoutes.NodeDetail> { 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),

View file

@ -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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<Node>> =
nodeRepository
.getNodes()
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val waypoints: StateFlow<Map<Int, Packet>> =
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<MapFilterState> =
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),
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map
const val MAP_STYLE_ID = "map_style_id"

View file

@ -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

View file

@ -115,7 +115,6 @@ fun NodeScreen(
modifier = Modifier.animateItem(),
thisNode = ourNode,
thatNode = node,
gpsFormat = state.gpsFormat,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
onAction = { menuItem ->

View file

@ -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<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2)
fun LinkedCoordinatesPreview() {
AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") }
}

View file

@ -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,

View file

@ -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,

View file

@ -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 = {})
}

View file

@ -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)
}
}

View file

@ -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, _ ->

View file

@ -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>) = 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<String>) = "%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<String> {
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<String> {
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<String> {
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.util
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
fun SharedPreferences.toggleBooleanPreference(
state: MutableStateFlow<Boolean>,
key: String,
onChanged: (Boolean) -> Unit = {},
) {
val newValue = !state.value
state.value = newValue
this.edit { putBoolean(key, newValue) }
onChanged(newValue)
}

View file

@ -763,4 +763,30 @@
<string name="nodes_queued_for_deletion">%d nodes queued for deletion:</string>
<string name="clean_node_database_description">Caution: This removes nodes from in-app and on-device databases.\nSelections are additive.</string>
<string name="connecting_to_device">Connecting to device</string>
<string name="map_type_normal">Normal</string>
<string name="map_type_satellite">Satellite</string>
<string name="map_type_terrain">Terrain</string>
<string name="map_type_hybrid">Hybrid</string>
<string name="manage_map_layers">Manage Map Layers</string>
<string name="map_layers_title">Map Layers</string>
<string name="no_map_layers_loaded">No custom layers loaded.</string>
<string name="add_layer_button">Add Layer</string>
<string name="hide_layer">Hide Layer</string>
<string name="show_layer">Show Layer</string>
<string name="remove_layer">Remove Layer</string>
<string name="add_layer">Add Layer</string>
<string name="nodes_at_this_location">Nodes at this location</string>
<string name="selected_map_type">Selected Map Type</string>
<string name="manage_custom_tile_sources">Manage Custom Tile Sources</string>
<string name="add_custom_tile_source">Add Custom Tile Source</string>
<string name="no_custom_tile_sources_found">No Custom Tile Sources</string>
<string name="edit_custom_tile_source">Edit Custom Tile Source</string>
<string name="delete_custom_tile_source">Delete Custom Tile Source</string>
<string name="name_cannot_be_empty">Name cannot be empty.</string>
<string name="provider_name_exists">Provider name exists.</string>
<string name="url_cannot_be_empty">URL cannot be empty.</string>
<string name="url_must_contain_placeholders">URL must contain placeholders.</string>
<string name="url_template">URL Template</string>
<string name="url_template_hint" translatable="false">https://a.tile.openstreetmap.org/{z}/{x}/{y}.png</string>
<string name="track_point">track point</string>
</resources>

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -1,21 +1,4 @@
<?xml version="1.0" ?>
<!--
~ Copyright (c) 2025 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<SmellBaseline>
<ManuallySuppressedIssues>
<ID>TooManyFunctions:ContactSharing.kt$com.geeksville.mesh.ui.ContactSharing.kt</ID>
@ -23,66 +6,19 @@
</ManuallySuppressedIssues>
<CurrentIssues>
<ID>ChainWrapping:Channel.kt$Channel$&amp;&amp;</ID>
<ID>ChainWrapping:CustomTileSource.kt$CustomTileSource.Companion.&lt;no name provided&gt;$+</ID>
<ID>ChainWrapping:SqlTileWriterExt.kt$SqlTileWriterExt$+</ID>
<ID>CommentSpacing:AppIntroduction.kt$AppIntroduction$//addSlide(SlideTwoFragment())</ID>
<ID>CommentSpacing:BLEException.kt$BLEConnectionClosing$/// Our interface is being shut down</ID>
<ID>CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Attempt to read from the fromRadio mailbox, if data is found broadcast it to android apps</ID>
<ID>CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// For testing</ID>
<ID>CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Our BLE device</ID>
<ID>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</ID>
<ID>CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Send a packet/command out the radio link</ID>
<ID>CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// Start a connection attempt</ID>
<ID>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</ID>
<ID>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</ID>
<ID>CommentSpacing:BluetoothInterface.kt$BluetoothInterface$/// We only try to set MTU once, because some buggy implementations fail</ID>
<ID>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</ID>
<ID>CommentSpacing:BluetoothInterface.kt$BluetoothInterface.Companion$/// this service UUID is publicly visible for scanning</ID>
<ID>CommentSpacing:Constants.kt$/// a bool true means we expect this condition to continue until, false means device might come back</ID>
<ID>CommentSpacing:ContextExtensions.kt$/// Utility function to hide the soft keyboard per stack overflow</ID>
<ID>CommentSpacing:ContextExtensions.kt$/// show a toast</ID>
<ID>CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib</ID>
<ID>CommentSpacing:DeferredExecution.kt$DeferredExecution$/// Queue some new work</ID>
<ID>CommentSpacing:DeferredExecution.kt$DeferredExecution$/// run all work in the queue and clear it to be ready to accept new work</ID>
<ID>CommentSpacing:DownloadButton.kt$//@Composable</ID>
<ID>CommentSpacing:DownloadButton.kt$//@Preview(showBackground = true)</ID>
<ID>CommentSpacing:DownloadButton.kt$//private fun DownloadButtonPreview() {</ID>
<ID>CommentSpacing:DownloadButton.kt$//}</ID>
<ID>CommentSpacing:Exceptions.kt$/// Convert any exceptions in this service call into a RemoteException that the client can</ID>
<ID>CommentSpacing:Exceptions.kt$/// then handle</ID>
<ID>CommentSpacing:Exceptions.kt$Exceptions$/// Set in Application.onCreate</ID>
<ID>CommentSpacing:Logging.kt$Logging$/// Kotlin assertions are disabled on android, so instead we use this assert helper</ID>
<ID>CommentSpacing:Logging.kt$Logging$/// Report an error (including messaging our crash reporter service if allowed</ID>
<ID>CommentSpacing:Logging.kt$Logging.Companion$/// If false debug logs will not be shown (but others might)</ID>
<ID>CommentSpacing:Logging.kt$Logging.Companion$/// if false NO logs will be shown, set this in the application based on BuildConfig.DEBUG</ID>
<ID>CommentSpacing:MeshServiceStarter.kt$/// Helper function to start running our service</ID>
<ID>CommentSpacing:MockInterface.kt$MockInterface$/// Generate a fake node info entry</ID>
<ID>CommentSpacing:MockInterface.kt$MockInterface$/// Generate a fake text message from a node</ID>
<ID>CommentSpacing:MockInterface.kt$MockInterface$/// Send a fake ack packet back if the sender asked for want_ack</ID>
<ID>CommentSpacing:NOAAWmsTileSource.kt$NOAAWmsTileSource$//array indexes for that data</ID>
<ID>CommentSpacing:NOAAWmsTileSource.kt$NOAAWmsTileSource$//used by geo server</ID>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// @return a nice human readable string for the distance, or null for unknown</ID>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// @return bearing to the other position in degrees</ID>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// @return distance in meters to some other node (or null if unknown)</ID>
<ID>CommentSpacing:NodeInfo.kt$NodeInfo$/// return the position if it is valid, else null</ID>
<ID>CommentSpacing:NodeInfo.kt$Position$/// @return bearing to the other position in degrees</ID>
<ID>CommentSpacing:NodeInfo.kt$Position$/// @return distance in meters to some other node (or null if unknown)</ID>
<ID>CommentSpacing:NodeInfo.kt$Position.Companion$/// Convert to a double representation of degrees</ID>
<ID>CommentSpacing:SafeBluetooth.kt$/// Return a standard BLE 128 bit UUID from the short 16 bit versions</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Drop our current connection and then requeue a connect as needed</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// If we have work we can do, start doing it.</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Restart any previous connect attempts</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Timeout before we declare a bluetooth operation failed (used for synchronous API operations only)</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// True if the current active connection is auto (possible for this to be false but autoConnect to be true</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// Users can access the GATT directly as needed</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// asyncronously turn notification on/off for a characteristic</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// from characteristic UUIDs to the handler function for notfies</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// helper glue to make sync continuations and then wait for the result</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$/// if we are in the first non-automated lowLevel connect.</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth$//com.geeksville.mesh.service.SafeBluetooth.closeGatt</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$//throw Exception("Mystery bluetooth failure - debug me")</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth.BluetoothContinuation$/// Connection work items are treated specially</ID>
<ID>CommentSpacing:SafeBluetooth.kt$SafeBluetooth.BluetoothContinuation$/// Start running a queued bit of work, return true for success or false for fatal bluetooth error</ID>
<ID>ConstructorParameterNaming:MeshLog.kt$MeshLog$@ColumnInfo(name = "message") val raw_message: String</ID>
<ID>ConstructorParameterNaming:MeshLog.kt$MeshLog$@ColumnInfo(name = "received_date") val received_date: Long</ID>
<ID>ConstructorParameterNaming:MeshLog.kt$MeshLog$@ColumnInfo(name = "type") val message_type: String</ID>
@ -90,92 +26,60 @@
<ID>ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "contact_key") val contact_key: String</ID>
<ID>ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "port_num") val port_num: Int</ID>
<ID>ConstructorParameterNaming:Packet.kt$Packet$@ColumnInfo(name = "received_time") val received_time: Long</ID>
<ID>CyclomaticComplexMethod:MapView.kt$@Composable fun MapView( model: UIViewModel = viewModel(), )</ID>
<ID>CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>CyclomaticComplexMethod:UIState.kt$UIViewModel$fun saveMessagesCSV(uri: Uri)</ID>
<ID>EmptyCatchBlock:MeshLog.kt$MeshLog${ }</ID>
<ID>EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt$()</ID>
<ID>EmptyDefaultConstructor:SqlTileWriterExt.kt$SqlTileWriterExt.SourceCount$()</ID>
<ID>EmptyFunctionBlock:NopInterface.kt$NopInterface${ }</ID>
<ID>EmptyFunctionBlock:NsdManager.kt$&lt;no name provided&gt;${ }</ID>
<ID>EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}</ID>
<ID>FinalNewline:AppIntroduction.kt$com.geeksville.mesh.AppIntroduction.kt</ID>
<ID>FinalNewline:AppPrefs.kt$com.geeksville.mesh.android.AppPrefs.kt</ID>
<ID>FinalNewline:ApplicationModule.kt$com.geeksville.mesh.ApplicationModule.kt</ID>
<ID>FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>FinalNewline:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt</ID>
<ID>FinalNewline:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt</ID>
<ID>FinalNewline:BluetoothViewModel.kt$com.geeksville.mesh.model.BluetoothViewModel.kt</ID>
<ID>FinalNewline:BootCompleteReceiver.kt$com.geeksville.mesh.service.BootCompleteReceiver.kt</ID>
<ID>FinalNewline:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt</ID>
<ID>FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>FinalNewline:CustomTileSource.kt$com.geeksville.mesh.model.map.CustomTileSource.kt</ID>
<ID>FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
<ID>FinalNewline:DeferredExecution.kt$com.geeksville.mesh.concurrent.DeferredExecution.kt</ID>
<ID>FinalNewline:DeviceVersion.kt$com.geeksville.mesh.model.DeviceVersion.kt</ID>
<ID>FinalNewline:DeviceVersionTest.kt$com.geeksville.mesh.model.DeviceVersionTest.kt</ID>
<ID>FinalNewline:ExpireChecker.kt$com.geeksville.mesh.android.ExpireChecker.kt</ID>
<ID>FinalNewline:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt</ID>
<ID>FinalNewline:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt</ID>
<ID>FinalNewline:Logging.kt$com.geeksville.mesh.android.Logging.kt</ID>
<ID>FinalNewline:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt</ID>
<ID>FinalNewline:NOAAWmsTileSource.kt$com.geeksville.mesh.model.map.NOAAWmsTileSource.kt</ID>
<ID>FinalNewline:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt</ID>
<ID>FinalNewline:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt</ID>
<ID>FinalNewline:OnlineTileSourceAuth.kt$com.geeksville.mesh.model.map.OnlineTileSourceAuth.kt</ID>
<ID>FinalNewline:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt</ID>
<ID>FinalNewline:QuickChatActionRepository.kt$com.geeksville.mesh.database.QuickChatActionRepository.kt</ID>
<ID>FinalNewline:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt</ID>
<ID>FinalNewline:RadioRepositoryModule.kt$com.geeksville.mesh.repository.radio.RadioRepositoryModule.kt</ID>
<ID>FinalNewline:RegularPreference.kt$com.geeksville.mesh.ui.common.components.RegularPreference.kt</ID>
<ID>FinalNewline:SafeBluetooth.kt$com.geeksville.mesh.service.SafeBluetooth.kt</ID>
<ID>FinalNewline:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt</ID>
<ID>FinalNewline:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt</ID>
<ID>FinalNewline:SerialInterface.kt$com.geeksville.mesh.repository.radio.SerialInterface.kt</ID>
<ID>FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
<ID>FinalNewline:SqlTileWriterExt.kt$com.geeksville.mesh.util.SqlTileWriterExt.kt</ID>
<ID>FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>FinalNewline:UsbBroadcastReceiver.kt$com.geeksville.mesh.repository.usb.UsbBroadcastReceiver.kt</ID>
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>ForbiddenComment:MapView.kt$// TODO: Accept filename input param from user</ID>
<ID>ForbiddenComment:SafeBluetooth.kt$SafeBluetooth$// TODO: display some kind of UI about restarting BLE</ID>
<ID>FunctionNaming:PacketDao.kt$PacketDao$@Query("DELETE FROM packet WHERE uuid=:uuid") suspend fun _delete(uuid: Long)</ID>
<ID>FunctionNaming:QuickChatActionDao.kt$QuickChatActionDao$@Query("Delete from quick_chat where uuid=:uuid") fun _delete(uuid: Long)</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$_degIn: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lat_a: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lat_b: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lng_a: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lng_b: Double</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %.6s %.7s", UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing )</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format( "%s%s %s%s %05d %05d", MGRS.zone, MGRS.band, MGRS.column, MGRS.row, MGRS.easting, MGRS.northing )</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%.5f %.5f", p.latitude, p.longitude)</ID>
<ID>ImplicitDefaultLocale:LocationUtils.kt$GPSFormat$String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3])</ID>
<ID>ImplicitDefaultLocale:NodeInfo.kt$NodeInfo$String.format("%d%%", batteryLevel)</ID>
<ID>LargeClass:MeshService.kt$MeshService : ServiceLogging</ID>
<ID>LongMethod:AmbientLightingConfigItemList.kt$@Composable fun AmbientLightingConfigItemList( ambientLightingConfig: ModuleConfigProtos.ModuleConfig.AmbientLightingConfig, enabled: Boolean, onSaveClicked: (ModuleConfigProtos.ModuleConfig.AmbientLightingConfig) -&gt; Unit, )</ID>
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigItemList( audioConfig: AudioConfig, enabled: Boolean, onSaveClicked: (AudioConfig) -&gt; Unit, )</ID>
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigItemList( messages: String, cannedMessageConfig: CannedMessageConfig, enabled: Boolean, onSaveClicked: (messages: String, config: CannedMessageConfig) -&gt; Unit, )</ID>
<ID>LongMethod:Contacts.kt$@Composable fun ContactsScreen( uiViewModel: UIViewModel = hiltViewModel(), onNavigate: (String) -&gt; Unit = {} )</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigItemList( deviceConfig: DeviceConfig, enabled: Boolean, onSaveClicked: (DeviceConfig) -&gt; Unit, )</ID>
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigItemList( displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -&gt; Unit, )</ID>
<ID>LongMethod:DropDownPreference.kt$@Composable fun &lt;T&gt; DropDownPreference( title: String, enabled: Boolean, items: List&lt;Pair&lt;T, String&gt;&gt;, selectedItem: T, onItemSelected: (T) -&gt; Unit, modifier: Modifier = Modifier, summary: String? = null, )</ID>
<ID>LongMethod:EditListPreference.kt$@Composable inline fun &lt;reified T&gt; EditListPreference( title: String, list: List&lt;T&gt;, maxCount: Int, enabled: Boolean, keyboardActions: KeyboardActions, crossinline onValuesChanged: (List&lt;T&gt;) -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigItemList( ringtone: String, extNotificationConfig: ExternalNotificationConfig, enabled: Boolean, onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -&gt; Unit, )</ID>
<ID>LongMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigItemList( mqttConfig: MQTTConfig, enabled: Boolean, onSaveClicked: (MQTTConfig) -&gt; Unit, )</ID>
<ID>LongMethod:MapView.kt$@Composable fun MapView( model: UIViewModel = viewModel(), )</ID>
<ID>LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigItemList( powerConfig: PowerConfig, enabled: Boolean, onSaveClicked: (PowerConfig) -&gt; Unit, )</ID>
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigItemList( serialConfig: SerialConfig, enabled: Boolean, onSaveClicked: (SerialConfig) -&gt; Unit, )</ID>
<ID>LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigItemList( storeForwardConfig: StoreForwardConfig, enabled: Boolean, onSaveClicked: (StoreForwardConfig) -&gt; Unit, )</ID>
<ID>LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigItemList( telemetryConfig: TelemetryConfig, enabled: Boolean, onSaveClicked: (TelemetryConfig) -&gt; Unit, )</ID>
<ID>LongMethod:UIState.kt$UIViewModel$fun saveMessagesCSV(uri: Uri)</ID>
<ID>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&lt;UsbManager&gt;, private val networkRepository: NetworkRepository, private val radioInterfaceService: RadioInterfaceService, )</ID>
<ID>LongParameterList:NOAAWmsTileSource.kt$NOAAWmsTileSource$( aName: String, aBaseUrl: Array&lt;String&gt;, layername: String, version: String, time: String?, srs: String, style: String?, format: String, )</ID>
<ID>LongParameterList:OnlineTileSourceAuth.kt$OnlineTileSourceAuth$( aName: String, aZoomLevel: Int, aZoomMaxLevel: Int, aTileSizePixels: Int, aImageFileNameEnding: String, aBaseUrl: Array&lt;String&gt;, pCopyright: String, tileSourcePolicy: TileSourcePolicy, layerName: String?, apiKey: String )</ID>
<ID>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, )</ID>
<ID>MagicNumber:BatteryInfo.kt$100</ID>
<ID>MagicNumber:BatteryInfo.kt$101</ID>
@ -188,7 +92,6 @@
<ID>MagicNumber:BatteryInfo.kt$79</ID>
<ID>MagicNumber:BatteryInfo.kt$80</ID>
<ID>MagicNumber:BluetoothInterface.kt$BluetoothInterface$1000</ID>
<ID>MagicNumber:BluetoothInterface.kt$BluetoothInterface$1500</ID>
<ID>MagicNumber:BluetoothInterface.kt$BluetoothInterface$500</ID>
<ID>MagicNumber:BluetoothInterface.kt$BluetoothInterface$512</ID>
<ID>MagicNumber:Channel.kt$0xff</ID>
@ -219,7 +122,6 @@
<ID>MagicNumber:ChannelSet.kt$960</ID>
<ID>MagicNumber:Contacts.kt$7</ID>
<ID>MagicNumber:Contacts.kt$8</ID>
<ID>MagicNumber:ContextServices.kt$33</ID>
<ID>MagicNumber:DataPacket.kt$DataPacket.CREATOR$16</ID>
<ID>MagicNumber:Debug.kt$3</ID>
<ID>MagicNumber:DeviceVersion.kt$DeviceVersion$100</ID>
@ -234,9 +136,6 @@
<ID>MagicNumber:EditListPreference.kt$12</ID>
<ID>MagicNumber:EditListPreference.kt$12345</ID>
<ID>MagicNumber:EditListPreference.kt$67890</ID>
<ID>MagicNumber:EditWaypointDialog.kt$123</ID>
<ID>MagicNumber:EditWaypointDialog.kt$128169</ID>
<ID>MagicNumber:EditWaypointDialog.kt$128205</ID>
<ID>MagicNumber:Extensions.kt$1000</ID>
<ID>MagicNumber:Extensions.kt$1440000</ID>
<ID>MagicNumber:Extensions.kt$24</ID>
@ -246,26 +145,14 @@
<ID>MagicNumber:LocationRepository.kt$LocationRepository$1000L</ID>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$30</ID>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$31</ID>
<ID>MagicNumber:LocationUtils.kt$0.8</ID>
<ID>MagicNumber:LocationUtils.kt$110540</ID>
<ID>MagicNumber:LocationUtils.kt$111320</ID>
<ID>MagicNumber:LocationUtils.kt$180</ID>
<ID>MagicNumber:LocationUtils.kt$1e-7</ID>
<ID>MagicNumber:LocationUtils.kt$360</ID>
<ID>MagicNumber:LocationUtils.kt$360.0</ID>
<ID>MagicNumber:LocationUtils.kt$3600.0</ID>
<ID>MagicNumber:LocationUtils.kt$60</ID>
<ID>MagicNumber:LocationUtils.kt$60.0</ID>
<ID>MagicNumber:LocationUtils.kt$6366000</ID>
<ID>MagicNumber:LocationUtils.kt$GPSFormat$3</ID>
<ID>MagicNumber:MQTTRepository.kt$MQTTRepository$512</ID>
<ID>MagicNumber:MapView.kt$0.5f</ID>
<ID>MagicNumber:MapView.kt$1.3</ID>
<ID>MagicNumber:MapView.kt$1000</ID>
<ID>MagicNumber:MapView.kt$1024.0</ID>
<ID>MagicNumber:MapView.kt$128205</ID>
<ID>MagicNumber:MapView.kt$12F</ID>
<ID>MagicNumber:MapView.kt$1e-7</ID>
<ID>MagicNumber:MapView.kt$&lt;no name provided&gt;$1e7</ID>
<ID>MagicNumber:MapViewExtensions.kt$1e-5</ID>
<ID>MagicNumber:MapViewExtensions.kt$1e-7</ID>
@ -287,14 +174,6 @@
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$1.5f</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$1000</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$16</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$2000</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$32.776665</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$32.960758</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$96.733521</ID>
<ID>MagicNumber:MockInterface.kt$MockInterface$96.796989</ID>
<ID>MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$180</ID>
<ID>MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$256</ID>
<ID>MagicNumber:NOAAWmsTileSource.kt$NOAAWmsTileSource$360.0</ID>
@ -310,13 +189,11 @@
<ID>MagicNumber:NodeInfo.kt$NodeInfo$0xFF0000</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1000</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1000.0</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$15</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$16</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1609</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$1609.34</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$255</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$3.281</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$60</ID>
<ID>MagicNumber:NodeInfo.kt$NodeInfo$8</ID>
<ID>MagicNumber:NodeInfo.kt$Position$180</ID>
<ID>MagicNumber:NodeInfo.kt$Position$90</ID>
@ -354,102 +231,23 @@
<ID>MaxLineLength:AppPrefs.kt$FloatPref$fun get(thisRef: AppPrefs, prop: KProperty&lt;Float&gt;): Float</ID>
<ID>MaxLineLength:AppPrefs.kt$StringPref$fun get(thisRef: AppPrefs, prop: KProperty&lt;String&gt;): String</ID>
<ID>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 &gt;= 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) */</ID>
<ID>MaxLineLength:BluetoothInterface.kt$BluetoothInterface$*</ID>
<ID>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</ID>
<ID>MaxLineLength:BluetoothInterface.kt$BluetoothInterface$// The following optimization is not currently correct - because the device might be sleeping and come back with different BLE handles</ID>
<ID>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</ID>
<ID>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</ID>
<ID>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</ID>
<ID>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</ID>
<ID>MaxLineLength:BluetoothInterface.kt$BluetoothInterface$delay(1000)</ID>
<ID>MaxLineLength:BluetoothInterface.kt$BluetoothInterface$delay(500)</ID>
<ID>MaxLineLength:BluetoothInterface.kt$BluetoothInterface$if</ID>
<ID>MaxLineLength:BluetoothInterface.kt$BluetoothInterface$null</ID>
<ID>MaxLineLength:BluetoothState.kt$BluetoothState$"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map { it.anonymize }})"</ID>
<ID>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</ID>
<ID>MaxLineLength:ContextServices.kt$val Context.locationManager: LocationManager get() = requireNotNull(getSystemService(Context.LOCATION_SERVICE) as? LocationManager?)</ID>
<ID>MaxLineLength:ContextServices.kt$val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?)</ID>
<ID>MaxLineLength:CustomTileSource.kt$CustomTileSource.Companion$arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?")</ID>
<ID>MaxLineLength:DataPacket.kt$DataPacket$val dataType: Int</ID>
<ID>MaxLineLength:LoRaConfigItemList.kt$value = if (isFocused || loraInput.overrideFrequency != 0f) loraInput.overrideFrequency else primaryChannel.radioFreq</ID>
<ID>MaxLineLength:LocationRepository.kt$LocationRepository$info("Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m")</ID>
<ID>MaxLineLength:MQTTRepository.kt$MQTTRepository.Companion$*</ID>
<ID>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! */</ID>
<ID>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</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$*</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$* Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException</ID>
<ID>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</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$// Nodes periodically send out position updates, but those updates might not contain a lat &amp; lon (because no GPS lock)</ID>
<ID>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)</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$// Update last seen for the node that sent the packet, but also for _our node_ because anytime a packet passes</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$// Update our last seen based on any valid timestamps. If the device didn't provide a timestamp make one</ID>
<ID>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)</ID>
<ID>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)</ID>
<ID>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</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$// because apps really only care about important updates of node state - which handledReceivedData will give them</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$// causes the phone to try and reconnect. If we fail downloading our initial radio state we don't want to</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$// logAssert(earlyReceivedPackets.size &lt; 128) // The max should normally be about 32, but if the device is messed up it might try to send forever</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$// note: no need to call startDeviceSleep(), because this exception could only have reached us if it was already called</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$MeshProtos.FromRadio.MQTTCLIENTPROXYMESSAGE_FIELD_NUMBER -&gt; handleMqttProxyMessage(proto.mqttClientProxyMessage)</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$debug("Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}")</ID>
<ID>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</ID>
<ID>MaxLineLength:MeshServiceBroadcasts.kt$MeshServiceBroadcasts$context.sendBroadcast(intent)</ID>
<ID>MaxLineLength:MeshServiceNotifications.kt$MeshServiceNotifications$// If running on really old versions of android (&lt;= 5.1.1) (possibly only cyanogen) we might encounter a bug with setting application specific icons</ID>
<ID>MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"")</ID>
<ID>MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"")</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE &amp;&amp; dist &lt; 1609 -&gt; "%.0f ft".format(dist.toDouble()*3.281)</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE &amp;&amp; dist &gt;= 1609 -&gt; "%.1f mi".format(dist / 1609.34)</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE &amp;&amp; dist &lt; 1000 -&gt; "%.0f m".format(dist.toDouble())</ID>
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE &amp;&amp; dist &gt;= 1000 -&gt; "%.1f km".format(dist / 1000.0)</ID>
<ID>MaxLineLength:NodeInfo.kt$Position$/**</ID>
<ID>MaxLineLength:NodeInfo.kt$Position$return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})"</ID>
<ID>MaxLineLength:PositionConfigItemList.kt$.</ID>
<ID>MaxLineLength:RadioInterfaceService.kt$RadioInterfaceService$/**</ID>
<ID>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</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth$*</ID>
<ID>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</ID>
<ID>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</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth$// Set these to null _before_ calling gatt.disconnect(), because we don't want the old lostConnectCallback to get called</ID>
<ID>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</ID>
<ID>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)</ID>
<ID>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</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth$?:</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth$if</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth$if (enable) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE</ID>
<ID>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</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$// After this execute reliable completes - we can continue with normal operations (see onReliableWriteCompleted)</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$// Note: if no work is pending (likely) we also just totally teardown and restart the connection, because we won't be</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$// We were not previously connected and we just failed with our non-auto connection attempt. Therefore we now need</ID>
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$// to do an autoconnection attempt. When that attempt succeeds/fails the normal callbacks will be called</ID>
<ID>MaxLineLength:ServiceClient.kt$ServiceClient$// Some phones seem to ahve a race where if you unbind and quickly rebind bindService returns false. Try</ID>
<ID>MaxLineLength:ServiceClient.kt$ServiceClient.&lt;no name provided&gt;$// If we start to close a service, it seems that there is a possibility a onServiceConnected event is the queue</ID>
<ID>MaxLineLength:SqlTileWriterExt.kt$SqlTileWriterExt$"select " + DatabaseFileArchive.COLUMN_KEY + "," + COLUMN_EXPIRES + "," + DatabaseFileArchive.COLUMN_PROVIDER + " from " + DatabaseFileArchive.TABLE + " limit ? offset ?"</ID>
<ID>MaxLineLength:StreamInterface.kt$StreamInterface$*</ID>
<ID>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)</ID>
<ID>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</ID>
<ID>MaxLineLength:StreamInterface.kt$StreamInterface$deliverPacket()</ID>
<ID>MaxLineLength:StreamInterface.kt$StreamInterface$lostSync()</ID>
<ID>MaxLineLength:StreamInterface.kt$StreamInterface$service.onDisconnect(isPermanent = true)</ID>
<ID>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</ID>
<ID>MaxLineLength:UIState.kt$UIViewModel$writer.appendLine("$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"")</ID>
<ID>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\"")</ID>
<ID>MayBeConst:AppPrefs.kt$AppPrefs.Companion$private val baseName = "appPrefs_"</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$doDiscoverServicesAndInit()</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$s.asyncDiscoverServices { discRes -&gt; 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" ) } }</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$safe?.asyncRequestMtu(512) { mtuRes -&gt; 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" ) } }</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$scheduleReconnect( "Unexpected error discovering services, forcing disconnect $ex" )</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$startConnect()</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$startWatchingFromNum()</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$warn("GATT was closed while discovering, assume we are shutting down")</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$warn("Interface is shutting down, so skipping discover")</ID>
<ID>MultiLineIfElse:BluetoothInterface.kt$BluetoothInterface$warn("Not connecting, because safe==null, someone must have closed us")</ID>
<ID>MultiLineIfElse:BluetoothRepository.kt$BluetoothRepository$bondedDevices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true }</ID>
<ID>MultiLineIfElse:BluetoothRepository.kt$BluetoothRepository$emptyList()</ID>
<ID>MultiLineIfElse:Channel.kt$Channel$"Custom"</ID>
<ID>MultiLineIfElse:Channel.kt$Channel$when (loraConfig.modemPreset) { ModemPreset.SHORT_TURBO -&gt; "ShortTurbo" ModemPreset.SHORT_FAST -&gt; "ShortFast" ModemPreset.SHORT_SLOW -&gt; "ShortSlow" ModemPreset.MEDIUM_FAST -&gt; "MediumFast" ModemPreset.MEDIUM_SLOW -&gt; "MediumSlow" ModemPreset.LONG_FAST -&gt; "LongFast" ModemPreset.LONG_SLOW -&gt; "LongSlow" ModemPreset.LONG_MODERATE -&gt; "LongMod" ModemPreset.VERY_LONG_SLOW -&gt; "VLongSlow" else -&gt; "Invalid" }</ID>
<ID>MultiLineIfElse:ChannelOption.kt$when (bandwidth) { 31 -&gt; .03125f 62 -&gt; .0625f 200 -&gt; .203125f 400 -&gt; .40625f 800 -&gt; .8125f 1600 -&gt; 1.6250f else -&gt; bandwidth / 1000f }</ID>
<ID>MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -&gt; } .setPositiveButton(R.string.accept) { _, _ -&gt; invokeFun() } .show()</ID>
<ID>MultiLineIfElse:ContextServices.kt$invokeFun()</ID>
<ID>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, )</ID>
<ID>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, )</ID>
<ID>MultiLineIfElse:EditTextPreference.kt$it.toDoubleOrNull()?.let { double -&gt; valueState = it onValueChanged(double) }</ID>
@ -458,126 +256,62 @@
<ID>MultiLineIfElse:EditTextPreference.kt$onValueChanged(it)</ID>
<ID>MultiLineIfElse:EditTextPreference.kt$valueState = it</ID>
<ID>MultiLineIfElse:Exceptions.kt$Exceptions.errormsg("ignoring exception", ex)</ID>
<ID>MultiLineIfElse:ExpireChecker.kt$ExpireChecker$doExpire()</ID>
<ID>MultiLineIfElse:Logging.kt$Logging$printlog(Log.ERROR, tag(), "$msg (exception ${ex.message})")</ID>
<ID>MultiLineIfElse:Logging.kt$Logging$printlog(Log.ERROR, tag(), "$msg")</ID>
<ID>MultiLineIfElse:MapViewWithLifecycle.kt$try { acquire() } catch (e: SecurityException) { errormsg("WakeLock permission exception: ${e.message}") } catch (e: IllegalStateException) { errormsg("WakeLock acquire() exception: ${e.message}") }</ID>
<ID>MultiLineIfElse:MapViewWithLifecycle.kt$try { release() } catch (e: IllegalStateException) { errormsg("WakeLock release() exception: ${e.message}") }</ID>
<ID>MultiLineIfElse:MeshService.kt$MeshService$getDataPacketById(packetId)?.let { p -&gt; if (p.status == m) return@handledLaunch packetRepository.get().updateMessageStatus(p, m) serviceBroadcasts.broadcastMessageStatus(packetId, m) }</ID>
<ID>MultiLineIfElse:MeshService.kt$MeshService.&lt;no name provided&gt;$try { sendNow(p) } catch (ex: Exception) { errormsg("Error sending message, so enqueueing", ex) enqueueForSending(p) }</ID>
<ID>MultiLineIfElse:NOAAWmsTileSource.kt$NOAAWmsTileSource$sb.append("service=WMS")</ID>
<ID>MultiLineIfElse:NodeInfo.kt$MeshUser$hwModel.name.replace('_', '-').replace('p', '.').lowercase()</ID>
<ID>MultiLineIfElse:NodeInfo.kt$MeshUser$null</ID>
<ID>MultiLineIfElse:RadioConfigViewModel.kt$RadioConfigViewModel$viewModelScope.launch { radioConfigRepository.replaceAllSettings(new) }</ID>
<ID>MultiLineIfElse:RadioInterfaceService.kt$RadioInterfaceService$startInterface()</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$cb</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$null</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$startNewWork()</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$throw AssertionError("currentWork was not null: $currentWork")</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$warn("wor completed, but we already killed it via failsafetimer? status=$status, res=$res")</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$work.completion.resume(Result.success(res) as Result&lt;Nothing&gt;)</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$work.completion.resumeWithException( BLEStatusException( status, "Bluetooth status=$status while doing ${work.tag}" ) )</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$completeWork(status, Unit)</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$completeWork(status, characteristic)</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$dropAndReconnect()</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$errormsg("Ignoring bogus onMtuChanged")</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$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) }</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$lostConnection("lost connection")</ID>
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.&lt;no name provided&gt;$warn("Received notification from $characteristic, but no handler registered")</ID>
<ID>NestedBlockDepth:LanguageUtils.kt$LanguageUtils$fun getLanguageTags(context: Context): Map&lt;String, String&gt;</ID>
<ID>NestedBlockDepth:MainActivity.kt$MainActivity$private fun onMeshConnectionChanged(newConnection: MeshService.ConnectionState)</ID>
<ID>NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage)</ID>
<ID>NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>NestedBlockDepth:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>NewLineAtEndOfFile:AppIntroduction.kt$com.geeksville.mesh.AppIntroduction.kt</ID>
<ID>NewLineAtEndOfFile:AppPrefs.kt$com.geeksville.mesh.android.AppPrefs.kt</ID>
<ID>NewLineAtEndOfFile:ApplicationModule.kt$com.geeksville.mesh.ApplicationModule.kt</ID>
<ID>NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>NewLineAtEndOfFile:BluetoothInterfaceFactory.kt$com.geeksville.mesh.repository.radio.BluetoothInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt</ID>
<ID>NewLineAtEndOfFile:BluetoothViewModel.kt$com.geeksville.mesh.model.BluetoothViewModel.kt</ID>
<ID>NewLineAtEndOfFile:BootCompleteReceiver.kt$com.geeksville.mesh.service.BootCompleteReceiver.kt</ID>
<ID>NewLineAtEndOfFile:CoroutineDispatchers.kt$com.geeksville.mesh.CoroutineDispatchers.kt</ID>
<ID>NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
<ID>NewLineAtEndOfFile:CustomTileSource.kt$com.geeksville.mesh.model.map.CustomTileSource.kt</ID>
<ID>NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt</ID>
<ID>NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt</ID>
<ID>NewLineAtEndOfFile:DeferredExecution.kt$com.geeksville.mesh.concurrent.DeferredExecution.kt</ID>
<ID>NewLineAtEndOfFile:DeviceVersion.kt$com.geeksville.mesh.model.DeviceVersion.kt</ID>
<ID>NewLineAtEndOfFile:DeviceVersionTest.kt$com.geeksville.mesh.model.DeviceVersionTest.kt</ID>
<ID>NewLineAtEndOfFile:ExpireChecker.kt$com.geeksville.mesh.android.ExpireChecker.kt</ID>
<ID>NewLineAtEndOfFile:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt</ID>
<ID>NewLineAtEndOfFile:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt</ID>
<ID>NewLineAtEndOfFile:Logging.kt$com.geeksville.mesh.android.Logging.kt</ID>
<ID>NewLineAtEndOfFile:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:NOAAWmsTileSource.kt$com.geeksville.mesh.model.map.NOAAWmsTileSource.kt</ID>
<ID>NewLineAtEndOfFile:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt</ID>
<ID>NewLineAtEndOfFile:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:OnlineTileSourceAuth.kt$com.geeksville.mesh.model.map.OnlineTileSourceAuth.kt</ID>
<ID>NewLineAtEndOfFile:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt</ID>
<ID>NewLineAtEndOfFile:QuickChatActionRepository.kt$com.geeksville.mesh.database.QuickChatActionRepository.kt</ID>
<ID>NewLineAtEndOfFile:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt</ID>
<ID>NewLineAtEndOfFile:RadioRepositoryModule.kt$com.geeksville.mesh.repository.radio.RadioRepositoryModule.kt</ID>
<ID>NewLineAtEndOfFile:RegularPreference.kt$com.geeksville.mesh.ui.common.components.RegularPreference.kt</ID>
<ID>NewLineAtEndOfFile:SafeBluetooth.kt$com.geeksville.mesh.service.SafeBluetooth.kt</ID>
<ID>NewLineAtEndOfFile:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt</ID>
<ID>NewLineAtEndOfFile:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt</ID>
<ID>NewLineAtEndOfFile:SerialInterface.kt$com.geeksville.mesh.repository.radio.SerialInterface.kt</ID>
<ID>NewLineAtEndOfFile:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:SqlTileWriterExt.kt$com.geeksville.mesh.util.SqlTileWriterExt.kt</ID>
<ID>NewLineAtEndOfFile:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>NewLineAtEndOfFile:UsbBroadcastReceiver.kt$com.geeksville.mesh.repository.usb.UsbBroadcastReceiver.kt</ID>
<ID>NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>NoBlankLineBeforeRbrace:BluetoothInterface.kt$BluetoothInterface$ </ID>
<ID>NoBlankLineBeforeRbrace:CustomTileSource.kt$CustomTileSource$ </ID>
<ID>NoBlankLineBeforeRbrace:DebugLogFile.kt$BinaryLogFile$ </ID>
<ID>NoBlankLineBeforeRbrace:NOAAWmsTileSource.kt$NOAAWmsTileSource$ </ID>
<ID>NoBlankLineBeforeRbrace:NopInterface.kt$NopInterface$ </ID>
<ID>NoBlankLineBeforeRbrace:OnlineTileSourceAuth.kt$OnlineTileSourceAuth$ </ID>
<ID>NoBlankLineBeforeRbrace:PositionTest.kt$PositionTest$ </ID>
<ID>NoConsecutiveBlankLines:AppIntroduction.kt$AppIntroduction$ </ID>
<ID>NoConsecutiveBlankLines:AppPrefs.kt$ </ID>
<ID>NoConsecutiveBlankLines:BluetoothInterface.kt$ </ID>
<ID>NoConsecutiveBlankLines:BluetoothInterface.kt$BluetoothInterface$ </ID>
<ID>NoConsecutiveBlankLines:BootCompleteReceiver.kt$ </ID>
<ID>NoConsecutiveBlankLines:Constants.kt$ </ID>
<ID>NoConsecutiveBlankLines:CustomTileSource.kt$ </ID>
<ID>NoConsecutiveBlankLines:CustomTileSource.kt$CustomTileSource.Companion$ </ID>
<ID>NoConsecutiveBlankLines:DebugLogFile.kt$ </ID>
<ID>NoConsecutiveBlankLines:DeferredExecution.kt$ </ID>
<ID>NoConsecutiveBlankLines:Exceptions.kt$ </ID>
<ID>NoConsecutiveBlankLines:IRadioInterface.kt$ </ID>
<ID>NoConsecutiveBlankLines:NOAAWmsTileSource.kt$NOAAWmsTileSource$ </ID>
<ID>NoConsecutiveBlankLines:NodeInfo.kt$ </ID>
<ID>NoConsecutiveBlankLines:SafeBluetooth.kt$ </ID>
<ID>NoConsecutiveBlankLines:SafeBluetooth.kt$SafeBluetooth$ </ID>
<ID>NoConsecutiveBlankLines:SqlTileWriterExt.kt$ </ID>
<ID>NoEmptyClassBody:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>NoSemicolons:DateUtils.kt$DateUtils$;</ID>
<ID>NoTrailingSpaces:ExpireChecker.kt$ExpireChecker$ </ID>
<ID>NoWildcardImports:BluetoothInterface.kt$import com.geeksville.mesh.service.*</ID>
<ID>NoWildcardImports:DeviceVersionTest.kt$import org.junit.Assert.*</ID>
<ID>NoWildcardImports:MockInterface.kt$import com.geeksville.mesh.*</ID>
<ID>NoWildcardImports:SafeBluetooth.kt$import android.bluetooth.*</ID>
<ID>NoWildcardImports:SafeBluetooth.kt$import kotlinx.coroutines.*</ID>
<ID>NoWildcardImports:UsbRepository.kt$import kotlinx.coroutines.flow.*</ID>
<ID>OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract</ID>
<ID>ParameterListWrapping:AppPrefs.kt$FloatPref$(thisRef: AppPrefs, prop: KProperty&lt;Float&gt;)</ID>
<ID>ParameterListWrapping:AppPrefs.kt$StringPref$(thisRef: AppPrefs, prop: KProperty&lt;String&gt;)</ID>
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, cont: Continuation&lt;BluetoothGattCharacteristic&gt;, timeout: Long = 0 )</ID>
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, cont: Continuation&lt;Unit&gt;, timeout: Long = 0 )</ID>
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, v: ByteArray, cont: Continuation&lt;BluetoothGattCharacteristic&gt;, timeout: Long = 0 )</ID>
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattDescriptor, cont: Continuation&lt;BluetoothGattDescriptor&gt;, timeout: Long = 0 )</ID>
<ID>RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex</ID>
<ID>ReturnCount:ChannelOption.kt$internal fun LoRaConfig.radioFreq(channelNum: Int): Float</ID>
<ID>ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>SpacingAroundCurly:AppPrefs.kt$FloatPref$}</ID>
<ID>SpacingAroundKeyword:AppPrefs.kt$AppPrefs$if</ID>
<ID>SpacingAroundKeyword:Exceptions.kt$if</ID>
<ID>SpacingAroundKeyword:Exceptions.kt$when</ID>
<ID>SpacingAroundOperators:NodeInfo.kt$NodeInfo$*</ID>
<ID>SpacingAroundRangeOperator:BatteryInfo.kt$..</ID>
<ID>StringTemplate:NodeInfo.kt$Position$${time}</ID>
<ID>SwallowedException:BluetoothInterface.kt$BluetoothInterface$ex: CancellationException</ID>
<ID>SwallowedException:ChannelSet.kt$ex: Throwable</ID>
<ID>SwallowedException:DeviceVersion.kt$DeviceVersion$e: Exception</ID>
@ -587,7 +321,6 @@
<ID>SwallowedException:MeshService.kt$MeshService$e: TimeoutException</ID>
<ID>SwallowedException:MeshService.kt$MeshService$ex: BLEException</ID>
<ID>SwallowedException:MeshService.kt$MeshService$ex: CancellationException</ID>
<ID>SwallowedException:MeshService.kt$MeshService$ex: RadioNotConnectedException</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: DeadObjectException</ID>
<ID>SwallowedException:SafeBluetooth.kt$SafeBluetooth$ex: NullPointerException</ID>
@ -595,7 +328,6 @@
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:BluetoothInterface.kt$BluetoothInterface$ex: Exception</ID>
<ID>TooGenericExceptionCaught:Channel.kt$ex: Exception</ID>
<ID>TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:DeviceVersion.kt$DeviceVersion$e: Exception</ID>
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
@ -603,8 +335,8 @@
<ID>TooGenericExceptionCaught:LocationRepository.kt$LocationRepository$e: Exception</ID>
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MainActivity.kt$MainActivity$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MainActivity.kt$MainActivity$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:MapView.kt$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MapViewModel.kt$MapViewModel$e: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$e: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService.&lt;no name provided&gt;$ex: Exception</ID>
@ -624,9 +356,6 @@
<ID>TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen")</ID>
<ID>TooManyFunctions:AppPrefs.kt$AppPrefs</ID>
<ID>TooManyFunctions:BluetoothInterface.kt$BluetoothInterface : IRadioInterfaceLogging</ID>
<ID>TooManyFunctions:ContactSharing.kt$com.geeksville.mesh.ui.sharing.ContactSharing.kt</ID>
<ID>TooManyFunctions:ContextServices.kt$com.geeksville.mesh.android.ContextServices.kt</ID>
<ID>TooManyFunctions:LocationUtils.kt$com.geeksville.mesh.util.LocationUtils.kt</ID>
<ID>TooManyFunctions:MainActivity.kt$MainActivity : AppCompatActivityLogging</ID>
<ID>TooManyFunctions:MeshService.kt$MeshService : ServiceLogging</ID>
<ID>TooManyFunctions:MeshService.kt$MeshService$&lt;no name provided&gt; : Stub</ID>
@ -641,31 +370,9 @@
<ID>TopLevelPropertyNaming:Constants.kt$const val prefix = "com.geeksville.mesh"</ID>
<ID>UnusedPrivateMember:NOAAWmsTileSource.kt$NOAAWmsTileSource$private fun tile2lat(y: Int, z: Int): Double</ID>
<ID>UnusedPrivateMember:NOAAWmsTileSource.kt$NOAAWmsTileSource$private fun tile2lon(x: Int, z: Int): Double</ID>
<ID>UnusedPrivateMember:SafeBluetooth.kt$SafeBluetooth$private fun reconnect()</ID>
<ID>UnusedPrivateProperty:BluetoothInterface.kt$BluetoothInterface$/// For testing @Volatile private var isFirstTime = true</ID>
<ID>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</ID>
<ID>UnusedPrivateProperty:CustomTileSource.kt$CustomTileSource.Companion$private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP</ID>
<ID>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) } }</ID>
<ID>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) } }</ID>
<ID>UtilityClassWithPublicConstructor:CustomTileSource.kt$CustomTileSource</ID>
<ID>UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$// Size of square world map in meters, using WebMerc projection. private val MAP_SIZE = 20037508.34789244 * 2</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$// Web Mercator n/w corner of the map. private val TILE_ORIGIN = doubleArrayOf(-20037508.34789244, 20037508.34789244)</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$// array indexes for array to hold bounding boxes. private val MINX = 0</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$//array indexes for that data private val ORIG_X = 0</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val MAXX = 1</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val MAXY = 3</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val MINY = 2</ID>
<ID>VariableNaming:NOAAWmsTileSource.kt$NOAAWmsTileSource$private val ORIG_Y = 1 // "</ID>
<ID>VariableNaming:SafeBluetooth.kt$SafeBluetooth$// Our own custom BLE status codes private val STATUS_RELIABLE_WRITE_FAILED = 4403</ID>
<ID>VariableNaming:SafeBluetooth.kt$SafeBluetooth$private val STATUS_NOSTART = 4405</ID>
<ID>VariableNaming:SafeBluetooth.kt$SafeBluetooth$private val STATUS_SIMFAILURE = 4406</ID>
<ID>VariableNaming:SafeBluetooth.kt$SafeBluetooth$private val STATUS_TIMEOUT = 4404</ID>
<ID>WildcardImport:BluetoothInterface.kt$import com.geeksville.mesh.service.*</ID>
<ID>WildcardImport:DeviceVersionTest.kt$import org.junit.Assert.*</ID>
<ID>WildcardImport:MockInterface.kt$import com.geeksville.mesh.*</ID>
<ID>WildcardImport:SafeBluetooth.kt$import android.bluetooth.*</ID>
<ID>WildcardImport:SafeBluetooth.kt$import kotlinx.coroutines.*</ID>
<ID>WildcardImport:UsbRepository.kt$import kotlinx.coroutines.flow.*</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -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" }

View file

@ -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})"
}

View file

@ -15,6 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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",
)
)
}

View file

@ -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