mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: maps (#2097)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
c05f434ff2
commit
87e50e03ea
76 changed files with 4188 additions and 1830 deletions
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
1
.github/workflows/reusable-android-build.yml
vendored
1
.github/workflows/reusable-android-build.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -29,4 +29,6 @@ keystore.properties
|
|||
|
||||
# VS code
|
||||
.vscode/settings.json
|
||||
|
||||
# Secrets
|
||||
/secrets.properties
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = {})
|
||||
}
|
||||
|
|
@ -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 = {})
|
||||
//}
|
||||
// }
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
71
app/src/fdroid/java/com/geeksville/mesh/util/MapUtils.kt
Normal file
71
app/src/fdroid/java/com/geeksville/mesh/util/MapUtils.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
40
app/src/google/AndroidManifest.xml
Normal file
40
app/src/google/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
65
app/src/google/java/com/geeksville/mesh/di/MapModule.kt
Normal file
65
app/src/google/java/com/geeksville/mesh/di/MapModule.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
701
app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt
Normal file
701
app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt
Normal 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)
|
||||
383
app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt
Normal file
383
app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
36
app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt
Normal file
36
app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt
Normal 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 = {})
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt
Normal file
106
app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
20
app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt
Normal file
20
app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ fun NodeScreen(
|
|||
modifier = Modifier.animateItem(),
|
||||
thisNode = ourNode,
|
||||
thatNode = node,
|
||||
gpsFormat = state.gpsFormat,
|
||||
distanceUnits = state.distanceUnits,
|
||||
tempInFahrenheit = state.tempInFahrenheit,
|
||||
onAction = { menuItem ->
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, _ ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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$&&</ID>
|
||||
<ID>ChainWrapping:CustomTileSource.kt$CustomTileSource.Companion.<no name provided>$+</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.<no name provided>$//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$<no name provided>${ }</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) -> Unit, )</ID>
|
||||
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigItemList( audioConfig: AudioConfig, enabled: Boolean, onSaveClicked: (AudioConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigItemList( messages: String, cannedMessageConfig: CannedMessageConfig, enabled: Boolean, onSaveClicked: (messages: String, config: CannedMessageConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:Contacts.kt$@Composable fun ContactsScreen( uiViewModel: UIViewModel = hiltViewModel(), onNavigate: (String) -> Unit = {} )</ID>
|
||||
<ID>LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigItemList( deviceConfig: DeviceConfig, enabled: Boolean, onSaveClicked: (DeviceConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigItemList( displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:DropDownPreference.kt$@Composable fun <T> DropDownPreference( title: String, enabled: Boolean, items: List<Pair<T, String>>, selectedItem: T, onItemSelected: (T) -> Unit, modifier: Modifier = Modifier, summary: String? = null, )</ID>
|
||||
<ID>LongMethod:EditListPreference.kt$@Composable inline fun <reified T> EditListPreference( title: String, list: List<T>, maxCount: Int, enabled: Boolean, keyboardActions: KeyboardActions, crossinline onValuesChanged: (List<T>) -> Unit, modifier: Modifier = Modifier, )</ID>
|
||||
<ID>LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigItemList( ringtone: String, extNotificationConfig: ExternalNotificationConfig, enabled: Boolean, onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigItemList( mqttConfig: MQTTConfig, enabled: Boolean, onSaveClicked: (MQTTConfig) -> 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) -> 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) -> Unit, )</ID>
|
||||
<ID>LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigItemList( storeForwardConfig: StoreForwardConfig, enabled: Boolean, onSaveClicked: (StoreForwardConfig) -> Unit, )</ID>
|
||||
<ID>LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigItemList( telemetryConfig: TelemetryConfig, enabled: Boolean, onSaveClicked: (TelemetryConfig) -> 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<UsbManager>, private val networkRepository: NetworkRepository, private val radioInterfaceService: RadioInterfaceService, )</ID>
|
||||
<ID>LongParameterList:NOAAWmsTileSource.kt$NOAAWmsTileSource$( aName: String, aBaseUrl: Array<String>, 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<String>, 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$<no name provided>$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<Float>): Float</ID>
|
||||
<ID>MaxLineLength:AppPrefs.kt$StringPref$fun get(thisRef: AppPrefs, prop: KProperty<String>): 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 >= 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 & 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 < 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 -> 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 (<= 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 && dist < 1609 -> "%.0f ft".format(dist.toDouble()*3.281)</ID>
|
||||
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 -> "%.1f mi".format(dist / 1609.34)</ID>
|
||||
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 -> "%.0f m".format(dist.toDouble())</ID>
|
||||
<ID>MaxLineLength:NodeInfo.kt$NodeInfo$prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 -> "%.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.<no name provided>$// After this execute reliable completes - we can continue with normal operations (see onReliableWriteCompleted)</ID>
|
||||
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth.<no name provided>$// Note: if no work is pending (likely) we also just totally teardown and restart the connection, because we won't be</ID>
|
||||
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth.<no name provided>$// We were not previously connected and we just failed with our non-auto connection attempt. Therefore we now need</ID>
|
||||
<ID>MaxLineLength:SafeBluetooth.kt$SafeBluetooth.<no name provided>$// 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.<no name provided>$// 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 -> 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 -> 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 -> "ShortTurbo" ModemPreset.SHORT_FAST -> "ShortFast" ModemPreset.SHORT_SLOW -> "ShortSlow" ModemPreset.MEDIUM_FAST -> "MediumFast" ModemPreset.MEDIUM_SLOW -> "MediumSlow" ModemPreset.LONG_FAST -> "LongFast" ModemPreset.LONG_SLOW -> "LongSlow" ModemPreset.LONG_MODERATE -> "LongMod" ModemPreset.VERY_LONG_SLOW -> "VLongSlow" else -> "Invalid" }</ID>
|
||||
<ID>MultiLineIfElse:ChannelOption.kt$when (bandwidth) { 31 -> .03125f 62 -> .0625f 200 -> .203125f 400 -> .40625f 800 -> .8125f 1600 -> 1.6250f else -> bandwidth / 1000f }</ID>
|
||||
<ID>MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -> } .setPositiveButton(R.string.accept) { _, _ -> 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 -> 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 -> if (p.status == m) return@handledLaunch packetRepository.get().updateMessageStatus(p, m) serviceBroadcasts.broadcastMessageStatus(packetId, m) }</ID>
|
||||
<ID>MultiLineIfElse:MeshService.kt$MeshService.<no name provided>$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<Nothing>)</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth$work.completion.resumeWithException( BLEStatusException( status, "Bluetooth status=$status while doing ${work.tag}" ) )</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$completeWork(status, Unit)</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$completeWork(status, characteristic)</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$dropAndReconnect()</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$errormsg("Ignoring bogus onMtuChanged")</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$if (!characteristic.value.contentEquals(reliable)) { errormsg("A reliable write failed!") gatt.abortReliableWrite() completeWork( STATUS_RELIABLE_WRITE_FAILED, characteristic ) // skanky code to indicate failure } else { logAssert(gatt.executeReliableWrite()) // After this execute reliable completes - we can continue with normal operations (see onReliableWriteCompleted) }</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$lostConnection("lost connection")</ID>
|
||||
<ID>MultiLineIfElse:SafeBluetooth.kt$SafeBluetooth.<no name provided>$warn("Received notification from $characteristic, but no handler registered")</ID>
|
||||
<ID>NestedBlockDepth:LanguageUtils.kt$LanguageUtils$fun getLanguageTags(context: Context): Map<String, String></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<Float>)</ID>
|
||||
<ID>ParameterListWrapping:AppPrefs.kt$StringPref$(thisRef: AppPrefs, prop: KProperty<String>)</ID>
|
||||
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, cont: Continuation<BluetoothGattCharacteristic>, timeout: Long = 0 )</ID>
|
||||
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, cont: Continuation<Unit>, timeout: Long = 0 )</ID>
|
||||
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattCharacteristic, v: ByteArray, cont: Continuation<BluetoothGattCharacteristic>, timeout: Long = 0 )</ID>
|
||||
<ID>ParameterListWrapping:SafeBluetooth.kt$SafeBluetooth$( c: BluetoothGattDescriptor, cont: Continuation<BluetoothGattDescriptor>, 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.<no name provided>$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$<no name provided> : 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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue