refactor: maps (#2097)

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

View file

@ -19,7 +19,6 @@ package com.geeksville.mesh
import android.graphics.Color
import android.os.Parcelable
import com.geeksville.mesh.util.GPSFormat
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.bearing
import com.geeksville.mesh.util.latLongToMeter
@ -115,14 +114,6 @@ data class Position(
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.DEC(this)
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.DMS(this)
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.UTM(this)
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.MGRS(this)
else -> GPSFormat.DEC(this)
}
override fun toString(): String =
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
}

View file

@ -62,9 +62,14 @@ data class Node(
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
}
val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET
val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty()
val mismatchKey get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
val mismatchKey
get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
@ -72,20 +77,28 @@ data class Node(
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics.getDefaultInstance()
val batteryLevel get() = deviceMetrics.batteryLevel
val voltage get() = deviceMetrics.voltage
val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
val batteryLevel
get() = deviceMetrics.batteryLevel
val latitude get() = position.latitudeI * 1e-7
val longitude get() = position.longitudeI * 1e-7
val voltage
get() = deviceMetrics.voltage
private fun hasValidPosition(): Boolean {
return latitude != 0.0 && longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
}
val batteryStr
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() }
val latitude
get() = position.latitudeI * 1e-7
val longitude
get() = position.longitudeI * 1e-7
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
val validPosition: MeshProtos.Position?
get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
fun distance(o: Node): Int? = when {
@ -103,70 +116,58 @@ data class Node(
else -> com.geeksville.mesh.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
}
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
else -> GPSFormat.toDEC(latitude, longitude)
}
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String {
val temp = if (temperature != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature))
val temp =
if (temperature != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature))
} else {
"%.1f°C".format(temperature)
}
} else {
"%.1f°C".format(temperature)
null
}
} else {
null
}
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
val soilTemperatureStr = if (soilTemperature != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
val soilTemperatureStr =
if (soilTemperature != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
} else {
"%.1f°C".format(soilTemperature)
}
} else {
"%.1f°C".format(soilTemperature)
null
}
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if (soilMoisture in soilMoistureRange && soilTemperature != 0f) {
"%d%%".format(soilMoisture)
} else { null }
} else {
null
}
val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null
val current = if (current != 0f) "%.1fmA".format(current) else null
val iaq = if (iaq != 0) "IAQ: $iaq" else null
return listOfNotNull(
temp,
humidity,
soilTemperatureStr,
soilMoisture,
voltage,
current,
iaq,
).joinToString(" ")
return listOfNotNull(temp, humidity, soilTemperatureStr, soilMoisture, voltage, current, iaq).joinToString(" ")
}
private fun PaxcountProtos.Paxcount.getDisplayString() =
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
fun getTelemetryString(isFahrenheit: Boolean = false): String {
return listOfNotNull(
paxcounter.getDisplayString(),
environmentMetrics.getDisplayString(isFahrenheit),
).joinToString(" ")
}
fun getTelemetryString(isFahrenheit: Boolean = false): String =
listOfNotNull(paxcounter.getDisplayString(), environmentMetrics.getDisplayString(isFahrenheit))
.joinToString(" ")
}
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in listOf(
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
)
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
listOf(
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
)

View file

@ -64,10 +64,10 @@ import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.util.getShortDate
import com.geeksville.mesh.util.positionToMeter
import com.geeksville.mesh.util.toggleBooleanPreference
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@ -82,7 +82,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
@ -102,27 +101,27 @@ import kotlin.math.roundToInt
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
// name and if the result is 3 or more characters, use the first three characters. If not, just take
// the first 3 characters of the original name.
fun getInitials(nameIn: String): String {
val nchars = 4
val minchars = 2
val name = nameIn.trim().withoutEmojis()
fun getInitials(fullName: String): String {
val maxInitialLength = 4
val minWordCountForInitials = 2
val name = fullName.trim().withoutEmojis()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials =
when (words.size) {
in 0 until minchars -> {
val nm =
in 0 until minWordCountForInitials -> {
val nameWithoutVowels =
if (name.isNotEmpty()) {
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
} else {
""
}
if (nm.length >= nchars) nm else name
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
}
else -> words.map { it.first() }.joinToString("")
}
return initials.take(nchars)
return initials.take(maxInitialLength)
}
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
@ -161,7 +160,6 @@ data class NodesUiState(
val includeUnknown: Boolean = false,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
val gpsFormat: Int = 0,
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
val showDetails: Boolean = false,
@ -185,7 +183,7 @@ data class Contact(
val nodeColors: Pair<Int, Int>? = null,
)
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
@HiltViewModel
class UIViewModel
@Inject
@ -193,7 +191,7 @@ constructor(
private val app: Application,
private val nodeDB: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val radioInterfaceService: RadioInterfaceService,
radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: MeshLogRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val packetRepository: PacketRepository,
@ -301,9 +299,6 @@ constructor(
val meshService: IMeshService?
get() = radioConfigRepository.meshService
val selectedBluetooth
get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x'
private val _localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
val localConfig: StateFlow<LocalConfig> = _localConfig
val config
@ -338,11 +333,6 @@ constructor(
private val onlyOnline = MutableStateFlow(preferences.getBoolean("only-online", false))
private val onlyDirect = MutableStateFlow(preferences.getBoolean("only-direct", false))
private val onlyFavorites = MutableStateFlow(preferences.getBoolean("only-favorites", false))
private val showWaypointsOnMap = MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true))
private val showPrecisionCircleOnMap =
MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true))
private val _showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false))
val showIgnored: StateFlow<Boolean> = _showIgnored
@ -351,6 +341,7 @@ constructor(
private val _hasShownNotPairedWarning =
MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false))
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
fun suppressNoPairedWarning() {
@ -358,40 +349,22 @@ constructor(
preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) }
}
private fun toggleBooleanPreference(
state: MutableStateFlow<Boolean>,
key: String,
onChanged: (Boolean) -> Unit = {},
) {
val newValue = !state.value
state.value = newValue
preferences.edit { putBoolean(key, newValue) }
onChanged(newValue)
}
fun toggleShowIgnored() = preferences.toggleBooleanPreference(_showIgnored, "show-ignored")
fun toggleShowIgnored() = toggleBooleanPreference(_showIgnored, "show-ignored")
fun toggleShowQuickChat() = toggleBooleanPreference(_showQuickChat, "show-quick-chat")
fun toggleShowQuickChat() = preferences.toggleBooleanPreference(_showQuickChat, "show-quick-chat")
fun setSortOption(sort: NodeSortOption) {
nodeSortOption.value = sort
preferences.edit { putInt("node-sort-option", sort.ordinal) }
}
fun toggleShowDetails() = toggleBooleanPreference(showDetails, "show-details")
fun toggleShowDetails() = preferences.toggleBooleanPreference(showDetails, "show-details")
fun toggleIncludeUnknown() = toggleBooleanPreference(includeUnknown, "include-unknown")
fun toggleIncludeUnknown() = preferences.toggleBooleanPreference(includeUnknown, "include-unknown")
fun toggleOnlyOnline() = toggleBooleanPreference(onlyOnline, "only-online")
fun toggleOnlyOnline() = preferences.toggleBooleanPreference(onlyOnline, "only-online")
fun toggleOnlyDirect() = toggleBooleanPreference(onlyDirect, "only-direct")
fun toggleOnlyFavorites() = toggleBooleanPreference(onlyFavorites, "only-favorites")
fun toggleShowWaypointsOnMap() = toggleBooleanPreference(showWaypointsOnMap, "show-waypoints-on-map")
fun toggleShowPrecisionCircleOnMap() =
toggleBooleanPreference(showPrecisionCircleOnMap, "show-precision-circle-on-map")
fun toggleOnlyDirect() = preferences.toggleBooleanPreference(onlyDirect, "only-direct")
data class NodeFilterState(
val filterText: String,
@ -425,7 +398,6 @@ constructor(
includeUnknown = filterFlow.includeUnknown,
onlyOnline = filterFlow.onlyOnline,
onlyDirect = filterFlow.onlyDirect,
gpsFormat = profile.config.display.gpsFormat.number,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
showDetails = showDetails,
@ -474,22 +446,6 @@ constructor(
initialValue = 0,
)
data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean)
val mapFilterStateFlow: StateFlow<MapFilterState> =
combine(onlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) {
favoritesOnly,
showWaypoints,
showPrecisionCircle,
->
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MapFilterState(false, true, true),
)
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeDB.myNodeInfo
@ -497,22 +453,15 @@ constructor(
val ourNodeInfo: StateFlow<Node?>
get() = nodeDB.ourNodeInfo
val nodesWithPosition
get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }
var mapStyleId: Int
get() = preferences.getInt(MAP_STYLE_ID, 0)
set(value) = preferences.edit { putInt(MAP_STYLE_ID, value) }
fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST)
val snackbarState = SnackbarHostState()
private val snackBarHostState = SnackbarHostState()
fun showSnackbar(text: Int) = showSnackbar(app.getString(text))
fun showSnackBar(text: Int) = showSnackBar(app.getString(text))
fun showSnackbar(text: String) = viewModelScope.launch { snackbarState.showSnackbar(text) }
fun showSnackBar(text: String) = viewModelScope.launch { snackBarHostState.showSnackbar(text) }
init {
radioConfigRepository.errorMessage
@ -615,15 +564,6 @@ constructor(
initialValue = emptyList(),
)
val waypoints =
packetRepository.getWaypoints().mapLatest { list ->
list
.associateBy { packet -> packet.data.waypoint!!.id }
.filterValues {
it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000
}
}
fun generatePacketId(): Int? {
return try {
meshService?.packetId
@ -749,8 +689,6 @@ constructor(
val connectionState
get() = radioConfigRepository.connectionState
fun isConnected() = isConnectedStateFlow.value
val isConnectedStateFlow =
radioConfigRepository.connectionState
.map { it.isConnected() }
@ -763,7 +701,7 @@ constructor(
fun requestChannelUrl(url: Uri) = runCatching { _requestChannelSet.value = url.toChannelSet() }
.onFailure { ex ->
errormsg("Channel url error: ${ex.message}")
showSnackbar(R.string.channel_invalid)
showSnackBar(R.string.channel_invalid)
}
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
@ -921,6 +859,7 @@ constructor(
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
@ -939,7 +878,9 @@ constructor(
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
nodePositions[
proto.from.takeIf { it != 0 } ?: myNodeNum,
] = position
}
}
@ -972,9 +913,9 @@ constructor(
""
} else {
positionToMeter(
rxPosition!!, // Use rxPosition but only if rxPos was
Position(rxPosition!!), // Use rxPosition but only if rxPos was
// valid
senderPosition!!, // Use senderPosition but only if
Position(senderPosition!!), // Use senderPosition but only if
// senderPos was valid
)
.roundToInt()
@ -998,7 +939,8 @@ constructor(
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
// elevation,rx snr,distance,hop limit,payload
// elevation,rx
// snr,distance,hop limit,payload
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",

View file

@ -1,212 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.model.map
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
class CustomTileSource {
companion object {
val OPENWEATHER_RADAR = OnlineTileSourceAuth(
"Open Weather Map", 1, 22, 256, ".png", arrayOf(
"https://tile.openweathermap.org/map/"
), "Openweathermap",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK
or TileSourcePolicy.FLAG_NO_PREVENTIVE
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
),
"precipitation",
""
)
//
// val RAIN_VIEWER = object : OnlineTileSourceBase(
// "RainViewer", 1, 15, 256, ".png", arrayOf(
// "https://tilecache.rainviewer.com/v2/coverage/"
// ), "RainViewer",
// TileSourcePolicy(
// 4,
// TileSourcePolicy.FLAG_NO_BULK
// or TileSourcePolicy.FLAG_NO_PREVENTIVE
// or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
// or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
// )
// ) {
// override fun getTileURLString(pMapTileIndex: Long): String {
// return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
// .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
// + "/" + MapTileIndex.getX(pMapTileIndex)
// + mImageFilenameEnding)
// }
// }
private val ESRI_IMAGERY = object : OnlineTileSourceBase(
"ESRI World Overview", 1, 20, 256, ".jpg", arrayOf(
"https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
), "Esri, Maxar, Earthstar Geographics, and the GIS User Community",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK
or TileSourcePolicy.FLAG_NO_PREVENTIVE
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
)
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
+ "/" + MapTileIndex.getX(pMapTileIndex)
+ mImageFilenameEnding)
}
}
private val ESRI_WORLD_TOPO = object : OnlineTileSourceBase(
"ESRI World TOPO",
1,
20,
256,
".jpg",
arrayOf(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"
),
"Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK
or TileSourcePolicy.FLAG_NO_PREVENTIVE
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
)
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
+ "/" + MapTileIndex.getX(pMapTileIndex)
+ mImageFilenameEnding)
}
}
private val USGS_HYDRO_CACHE = object : OnlineTileSourceBase(
"USGS Hydro Cache",
0,
18,
256,
"",
arrayOf(
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"
),
"USGS",
TileSourcePolicy(
2,
TileSourcePolicy.FLAG_NO_PREVENTIVE
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
)
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
+ "/" + MapTileIndex.getX(pMapTileIndex)
+ mImageFilenameEnding)
}
}
private val USGS_SHADED_RELIEF = object : OnlineTileSourceBase(
"USGS Shaded Relief Only",
0,
18,
256,
"",
arrayOf(
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/"
),
"USGS",
TileSourcePolicy(
2,
TileSourcePolicy.FLAG_NO_PREVENTIVE
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
)
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
+ "/" + MapTileIndex.getX(pMapTileIndex)
+ mImageFilenameEnding)
}
}
/**
* WMS TILE SERVER
* More research is required to get this to function correctly with overlays
*/
val NOAA_RADAR_WMS = NOAAWmsTileSource(
"Recent Weather Radar",
arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?"),
"1",
"1.1.0",
"",
"EPSG%3A3857",
"",
"image/png"
)
/**
* ===============================================================================================
*/
private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
/**
* Source for each available [ITileSource] and their display names.
*/
val mTileSources: Map<ITileSource, String> = mapOf(
MAPNIK to "OpenStreetMap",
USGS_TOPO to "USGS TOPO",
OPEN_TOPO to "Open TOPO",
ESRI_WORLD_TOPO to "ESRI World TOPO",
USGS_SAT to "USGS Satellite",
ESRI_IMAGERY to "ESRI World Overview",
)
fun getTileSource(index: Int): ITileSource {
return mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE
}
fun getTileSource(aName: String): ITileSource {
for (tileSource: ITileSource in mTileSources.keys) {
if (tileSource.name().equals(aName)) {
return tileSource
}
}
throw IllegalArgumentException("No such tile source: $aName")
}
}
}

View file

@ -1,148 +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 android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.MotionEvent
import com.geeksville.mesh.android.dpToPx
import com.geeksville.mesh.android.spToPx
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) {
companion object {
private const val LABEL_CORNER_RADIUS_DP = 4f
private const val LABEL_Y_OFFSET_DP = 34f
private const val FONT_SIZE_SP = 14f
private const val EMOJI_FONT_SIZE_SP = 20f
}
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 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 var onLongClickListener: (() -> Boolean)? = null
fun setOnLongClickListener(listener: () -> Boolean) {
onLongClickListener = listener
}
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 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)
)
}
override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean {
val touched = hitTest(event, mapView)
if (touched && this.id != null) {
return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView)
}
return super.onLongPress(event, mapView)
}
@Suppress("MagicNumber")
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
super.draw(c, osmv, false)
val p = mPositionPixels
val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat()))
bgRect.inset(-8F, -2F)
if (mLabel.isNotEmpty()) {
c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint)
c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint)
}
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
}
outlinePaint.apply {
color = nodeColor
alpha = 64
}
}
polygon.draw(c, osmv, false)
}
}
}

View file

@ -1,178 +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 android.content.res.Resources
import android.util.Log
import org.osmdroid.api.IMapView
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
import kotlin.math.atan
import kotlin.math.pow
import kotlin.math.sinh
open class NOAAWmsTileSource(
aName: String,
aBaseUrl: Array<String>,
layername: String,
version: String,
time: String?,
srs: String,
style: String?,
format: String,
) : OnlineTileSourceBase(
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
)
) {
// array indexes for array to hold bounding boxes.
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)
//array indexes for that data
private val ORIG_X = 0
private val ORIG_Y = 1 // "
// Size of square world map in meters, using WebMerc projection.
private val MAP_SIZE = 20037508.34789244 * 2
private var layer = ""
private var version = "1.1.0"
private var srs = "EPSG%3A3857" //used by geo server
private var format = ""
private var time = ""
private var style: String? = null
private var forceHttps = false
private var forceHttp = false
init {
Log.i(IMapView.LOGTAG, "WMS support is BETA. Please report any issues")
layer = layername
this.version = version
this.srs = srs
this.style = style
this.format = format
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
// )
// }
private fun tile2lon(x: Int, z: Int): Double {
return 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())
return Math.toDegrees(atan(sinh(n)))
}
// 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 bbox = DoubleArray(4)
bbox[MINX] = minx
bbox[MINY] = miny
bbox[MAXX] = maxx
bbox[MAXY] = maxy
return bbox
}
fun isForceHttps(): Boolean {
return forceHttps
}
fun setForceHttps(forceHttps: Boolean) {
this.forceHttps = forceHttps
}
fun isForceHttp(): Boolean {
return forceHttp
}
fun setForceHttp(forceHttp: Boolean) {
this.forceHttp = forceHttp
}
override fun getTileURLString(pMapTileIndex: Long): String? {
var baseUrl = baseUrl
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")
sb.append("&request=GetMap")
sb.append("&version=").append(version)
sb.append("&layers=").append(layer)
if (style != null) sb.append("&styles=").append(style)
sb.append("&format=").append(format)
sb.append("&transparent=true")
sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels)
sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels)
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])
Log.i(IMapView.LOGTAG, sb.toString())
return sb.toString()
}
private fun getSize(): String {
val height = Resources.getSystem().displayMetrics.heightPixels
val width = Resources.getSystem().displayMetrics.widthPixels
return "$width,$height"
}
}

View file

@ -1,64 +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.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
open class OnlineTileSourceAuth(
aName: String,
aZoomLevel: Int,
aZoomMaxLevel: Int,
aTileSizePixels: Int,
aImageFileNameEnding: String,
aBaseUrl: Array<String>,
pCopyright: String,
tileSourcePolicy: TileSourcePolicy,
layerName: String?,
apiKey: String
) :
OnlineTileSourceBase(
aName,
aZoomLevel,
aZoomMaxLevel,
aTileSizePixels,
aImageFileNameEnding,
aBaseUrl,
pCopyright,
tileSourcePolicy
) {
private var layerName = ""
private var apiKey = ""
init {
if (layerName != null) {
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"
}
}

View file

@ -1,201 +0,0 @@
package com.geeksville.mesh.model.map.clustering;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Point;
import android.view.MotionEvent;
import org.osmdroid.api.IGeoPoint;
import org.osmdroid.bonuspack.kml.KmlFeature;
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;
import java.util.ListIterator;
/**
* An overlay allowing to perform markers clustering.
* Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays.
* Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker. <br/>
*
* This abstract class provides the framework. Sub-classes have to implement the clustering algorithm,
* and the rendering of a cluster.
*
* @author M.Kergall
*
*/
public abstract class MarkerClusterer extends Overlay {
/** impossible value for zoom level, to force clustering */
protected static final int FORCE_CLUSTERING = -1;
protected ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
protected Point mPoint = new Point();
protected ArrayList<StaticCluster> mClusters = new ArrayList<StaticCluster>();
protected int mLastZoomLevel;
protected Bitmap mClusterIcon;
protected String mName, mDescription;
// abstract methods:
/** clustering algorithm */
public abstract ArrayList<StaticCluster> clusterer(MapView mapView);
/** Build the marker for a cluster. */
public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView);
/** build clusters markers to be used at next draw */
public abstract void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView);
public MarkerClusterer() {
super();
mLastZoomLevel = FORCE_CLUSTERING;
}
public void setName(String name){
mName = name;
}
public String getName(){
return mName;
}
public void setDescription(String description){
mDescription = description;
}
public String getDescription(){
return mDescription;
}
/** Set the cluster icon to be drawn when a cluster contains more than 1 marker.
* If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */
public void setIcon(Bitmap icon){
mClusterIcon = icon;
}
/** Add the Marker.
* Important: Markers added in a MarkerClusterer should not be added in the map overlays. */
public void add(MarkerWithLabel marker){
mItems.add(marker);
}
/** Force a rebuild of clusters at next draw, even without a zooming action.
* Should be done when you changed the content of a MarkerClusterer. */
public void invalidate(){
mLastZoomLevel = FORCE_CLUSTERING;
}
/** @return the Marker at id (starting at 0) */
public MarkerWithLabel getItem(int id){
return mItems.get(id);
}
/** @return the list of Markers. */
public ArrayList<MarkerWithLabel> getItems(){
return mItems;
}
protected void hideInfoWindows(){
for (MarkerWithLabel m : mItems){
if (m.isInfoWindowShown())
m.closeInfoWindow();
}
}
@Override public void draw(Canvas canvas, MapView mapView, boolean shadow) {
if (shadow)
return;
//if zoom has changed and mapView is now stable, rebuild clusters:
int zoomLevel = mapView.getZoomLevel();
if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){
hideInfoWindows();
mClusters = clusterer(mapView);
renderer(mClusters, canvas, mapView);
mLastZoomLevel = zoomLevel;
}
for (StaticCluster cluster:mClusters){
MarkerWithLabel marker = cluster.getMarker();
marker.draw(canvas, mapView, false);
}
}
public Iterable<StaticCluster> reversedClusters() {
return new Iterable<StaticCluster>() {
@Override
public Iterator<StaticCluster> iterator() {
final ListIterator<StaticCluster> i = mClusters.listIterator(mClusters.size());
return new Iterator<StaticCluster>() {
@Override
public boolean hasNext() {
return i.hasPrevious();
}
@Override
public StaticCluster next() {
return i.previous();
}
@Override
public void remove() {
i.remove();
}
};
}
};
}
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onSingleTapConfirmed(event, mapView))
return true;
}
return false;
}
@Override public boolean onLongPress(final MotionEvent event, final MapView mapView) {
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onLongPress(event, mapView))
return true;
}
return false;
}
@Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) {
for (StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onTouchEvent(event, mapView))
return true;
}
return false;
}
@Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) {
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onDoubleTap(event, mapView))
return true;
}
return false;
}
@Override public BoundingBox getBounds(){
if (mItems.size() == 0)
return null;
double minLat = Double.MAX_VALUE;
double minLon = Double.MAX_VALUE;
double maxLat = -Double.MAX_VALUE;
double maxLon = -Double.MAX_VALUE;
for (final MarkerWithLabel item : mItems) {
final double latitude = item.getPosition().getLatitude();
final double longitude = item.getPosition().getLongitude();
minLat = Math.min(minLat, latitude);
minLon = Math.min(minLon, longitude);
maxLat = Math.max(maxLat, latitude);
maxLon = Math.max(maxLon, longitude);
}
return new BoundingBox(maxLat, maxLon, minLat, minLon);
}
}

View file

@ -1,195 +0,0 @@
package com.geeksville.mesh.model.map.clustering;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.MotionEvent;
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;
/**
* Radius-based Clustering algorithm:
* create a cluster using the first point from the cloned list.
* All points that are found within the neighborhood are added to this cluster.
* Then all the neighbors and the main point are removed from the list of points.
* It continues until the list is empty.
*
* Largely inspired from GridMarkerClusterer by M.Kergall
*
* @author sidorovroman92@gmail.com
*/
public class RadiusMarkerClusterer extends MarkerClusterer {
protected int mMaxClusteringZoomLevel = 7;
protected int mRadiusInPixels = 100;
protected double mRadiusInMeters;
protected Paint mTextPaint;
private ArrayList<MarkerWithLabel> mClonedMarkers;
protected boolean mAnimated;
int mDensityDpi;
/** cluster icon anchor */
public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER;
/** anchor point to draw the number of markers inside the cluster icon */
public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER;
public RadiusMarkerClusterer(Context ctx) {
super();
mTextPaint = new Paint();
mTextPaint.setColor(Color.WHITE);
mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density);
mTextPaint.setFakeBoldText(true);
mTextPaint.setTextAlign(Paint.Align.CENTER);
mTextPaint.setAntiAlias(true);
Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster);
Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap();
setIcon(clusterIcon);
mAnimated = true;
mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi;
}
/** If you want to change the default text paint (color, size, font) */
public Paint getTextPaint(){
return mTextPaint;
}
/** Set the radius of clustering in pixels. Default is 100px. */
public void setRadius(int radius){
mRadiusInPixels = radius;
}
/** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled.
* You can put a high value to disable this feature. */
public void setMaxClusteringZoomLevel(int zoom){
mMaxClusteringZoomLevel = zoom;
}
/** Radius-Based clustering algorithm */
@Override public ArrayList<StaticCluster> clusterer(MapView mapView) {
ArrayList<StaticCluster> clusters = new ArrayList<StaticCluster>();
convertRadiusToMeters(mapView);
mClonedMarkers = new ArrayList<MarkerWithLabel>(mItems); //shallow copy
while (!mClonedMarkers.isEmpty()) {
MarkerWithLabel m = mClonedMarkers.get(0);
StaticCluster cluster = createCluster(m, mapView);
clusters.add(cluster);
}
return clusters;
}
private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) {
GeoPoint clusterPosition = m.getPosition();
StaticCluster cluster = new StaticCluster(clusterPosition);
cluster.add(m);
mClonedMarkers.remove(m);
if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) {
//above max level => block clustering:
return cluster;
}
Iterator<MarkerWithLabel> it = mClonedMarkers.iterator();
while (it.hasNext()) {
MarkerWithLabel neighbour = it.next();
double distance = clusterPosition.distanceToAsDouble(neighbour.getPosition());
if (distance <= mRadiusInMeters) {
cluster.add(neighbour);
it.remove();
}
}
return cluster;
}
@Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) {
MarkerWithLabel m = new MarkerWithLabel(mapView, "", null);
m.setPosition(cluster.getPosition());
m.setInfoWindow(null);
m.setAnchor(mAnchorU, mAnchorV);
Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi),
mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig());
Canvas iconCanvas = new Canvas(finalIcon);
iconCanvas.drawBitmap(mClusterIcon, 0, 0, null);
String text = "" + cluster.getSize();
int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent());
iconCanvas.drawText(text,
mTextAnchorU * finalIcon.getWidth(),
mTextAnchorV * finalIcon.getHeight() - textHeight / 2,
mTextPaint);
m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon));
return m;
}
@Override public void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView) {
for (StaticCluster cluster : clusters) {
if (cluster.getSize() == 1) {
//cluster has only 1 marker => use it as it is:
cluster.setMarker(cluster.getItem(0));
} else {
//only draw 1 Marker at Cluster center, displaying number of Markers contained
MarkerWithLabel m = buildClusterMarker(cluster, mapView);
cluster.setMarker(m);
}
}
}
private void convertRadiusToMeters(MapView mapView) {
Rect mScreenRect = mapView.getIntrinsicScreenRect(null);
int screenWidth = mScreenRect.right - mScreenRect.left;
int screenHeight = mScreenRect.bottom - mScreenRect.top;
BoundingBox bb = mapView.getBoundingBox();
double diagonalInMeters = bb.getDiagonalLengthInMeters();
double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight);
double metersInPixel = diagonalInMeters / diagonalInPixels;
mRadiusInMeters = mRadiusInPixels * metersInPixel;
}
public void setAnimation(boolean animate){
mAnimated = animate;
}
public void zoomOnCluster(MapView mapView, StaticCluster cluster){
BoundingBox bb = cluster.getBoundingBox();
if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) {
bb = bb.increaseByScale(2.3f);
mapView.zoomToBoundingBox(bb, true);
} else //all points exactly at the same place:
mapView.setExpectedCenter(bb.getCenterWithDateLine());
}
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) {
if (mAnimated && cluster.getSize() > 1)
zoomOnCluster(mapView, cluster);
return true;
}
}
return false;
}
}

View file

@ -1,67 +0,0 @@
package com.geeksville.mesh.model.map.clustering;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;
import com.geeksville.mesh.model.map.MarkerWithLabel;
import java.util.ArrayList;
/**
* Cluster of Markers.
* @author M.Kergall
*/
public class StaticCluster {
protected final ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
protected GeoPoint mCenter;
protected MarkerWithLabel mMarker;
public StaticCluster(GeoPoint center) {
mCenter = center;
}
public void setPosition(GeoPoint center){
mCenter = center;
}
public GeoPoint getPosition() {
return mCenter;
}
public int getSize() {
return mItems.size();
}
public MarkerWithLabel getItem(int index) {
return mItems.get(index);
}
public boolean add(MarkerWithLabel t) {
return mItems.add(t);
}
/** set the Marker to be displayed for this cluster */
public void setMarker(MarkerWithLabel marker){
mMarker = marker;
}
/** @return the Marker to be displayed for this cluster */
public MarkerWithLabel getMarker(){
return mMarker;
}
public BoundingBox getBoundingBox(){
if (getSize()==0)
return null;
GeoPoint p = getItem(0).getPosition();
BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude());
for (int i=1; i<getSize(); i++) {
p = getItem(i).getPosition();
double minLat = Math.min(bb.getLatSouth(), p.getLatitude());
double minLon = Math.min(bb.getLonWest(), p.getLongitude());
double maxLat = Math.max(bb.getLatNorth(), p.getLatitude());
double maxLon = Math.max(bb.getLonEast(), p.getLongitude());
bb.set(maxLat, maxLon, minLat, minLon);
}
return bb;
}
}

View file

@ -22,23 +22,19 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.map.MapView
import com.geeksville.mesh.ui.map.MapViewModel
import kotlinx.serialization.Serializable
sealed class MapRoutes {
@Serializable
data object Map : Route
@Serializable data object Map : Route
}
fun NavGraphBuilder.mapGraph(
navController: NavHostController,
uiViewModel: UIViewModel,
) {
fun NavGraphBuilder.mapGraph(navController: NavHostController, uiViewModel: UIViewModel, mapViewModel: MapViewModel) {
composable<MapRoutes.Map> {
MapView(
model = uiViewModel,
navigateToNodeDetails = {
navController.navigate(NodesRoutes.NodeDetailGraph(it))
},
uiViewModel = uiViewModel,
mapViewModel = mapViewModel,
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
}
}

View file

@ -34,6 +34,7 @@ import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
import com.geeksville.mesh.ui.debug.DebugScreen
import com.geeksville.mesh.ui.map.MapViewModel
import kotlinx.serialization.Serializable
enum class AdminRoute(@StringRes val title: Int) {
@ -71,6 +72,7 @@ fun NavGraph(
modifier: Modifier = Modifier,
uIViewModel: UIViewModel = hiltViewModel(),
bluetoothViewModel: BluetoothViewModel = hiltViewModel(),
mapViewModel: MapViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
) {
val isConnected by uIViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
@ -86,7 +88,7 @@ fun NavGraph(
) {
contactsGraph(navController, uIViewModel)
nodesGraph(navController, uIViewModel)
mapGraph(navController, uIViewModel)
mapGraph(navController, uIViewModel, mapViewModel)
channelsGraph(navController, uIViewModel)
connectionsGraph(navController, uIViewModel, bluetoothViewModel)
composable<Route.DebugPanel> { DebugScreen() }

View file

@ -23,8 +23,8 @@ import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.PermScanWifi
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.PermScanWifi
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Router
import androidx.compose.runtime.remember
@ -39,130 +39,91 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen
import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.metrics.HostMetricsLogScreen
import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
import com.geeksville.mesh.ui.metrics.PositionLogScreen
import com.geeksville.mesh.ui.metrics.PowerMetricsScreen
import com.geeksville.mesh.ui.metrics.SignalMetricsScreen
import com.geeksville.mesh.ui.metrics.TracerouteLogScreen
import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
import com.geeksville.mesh.ui.node.NodeDetailScreen
import com.geeksville.mesh.ui.node.NodeMapScreen
import com.geeksville.mesh.ui.node.NodeScreen
import kotlinx.serialization.Serializable
sealed class NodesRoutes {
@Serializable
data object Nodes : Route
@Serializable data object Nodes : Route
@Serializable
data object NodesGraph : Graph
@Serializable data object NodesGraph : Graph
@Serializable
data class NodeDetailGraph(val destNum: Int? = null) : Graph
@Serializable data class NodeDetailGraph(val destNum: Int? = null) : Graph
@Serializable
data class NodeDetail(val destNum: Int? = null) : Route
@Serializable data class NodeDetail(val destNum: Int? = null) : Route
}
sealed class NodeDetailRoutes {
@Serializable
data object DeviceMetrics : Route
@Serializable data object DeviceMetrics : Route
@Serializable
data object NodeMap : Route
@Serializable data object NodeMap : Route
@Serializable
data object PositionLog : Route
@Serializable data object PositionLog : Route
@Serializable
data object EnvironmentMetrics : Route
@Serializable data object EnvironmentMetrics : Route
@Serializable
data object SignalMetrics : Route
@Serializable data object SignalMetrics : Route
@Serializable
data object PowerMetrics : Route
@Serializable data object PowerMetrics : Route
@Serializable
data object TracerouteLog : Route
@Serializable data object TracerouteLog : Route
@Serializable
data object HostMetricsLog : Route
@Serializable data object HostMetricsLog : Route
@Serializable
data object PaxMetrics : Route
@Serializable data object PaxMetrics : Route
}
fun NavGraphBuilder.nodesGraph(
navController: NavHostController,
uiViewModel: UIViewModel,
) {
navigation<NodesRoutes.NodesGraph>(
startDestination = NodesRoutes.Nodes,
) {
fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UIViewModel) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes> {
NodeScreen(
model = uiViewModel,
navigateToMessages = {
navController.navigate(ContactsRoutes.Messages(it))
},
navigateToNodeDetails = {
navController.navigate(NodesRoutes.NodeDetailGraph(it))
},
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
}
nodeDetailGraph(navController, uiViewModel)
}
}
fun NavGraphBuilder.nodeDetailGraph(
navController: NavHostController,
uiViewModel: UIViewModel,
) {
navigation<NodesRoutes.NodeDetailGraph>(
startDestination = NodesRoutes.NodeDetail(),
) {
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewModel: UIViewModel) {
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
composable<NodesRoutes.NodeDetail> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
val parentRoute = backStackEntry.destination.parent!!.route!!
navController.getBackStackEntry(parentRoute)
}
val parentEntry =
remember(backStackEntry) {
val parentRoute = backStackEntry.destination.parent!!.route!!
navController.getBackStackEntry(parentRoute)
}
NodeDetailScreen(
uiViewModel = uiViewModel,
navigateToMessages = {
navController.navigate(ContactsRoutes.Messages(it))
},
onNavigate = {
navController.navigate(it)
},
onNavigateUp = {
navController.navigateUp()
},
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
onNavigate = { navController.navigate(it) },
onNavigateUp = { navController.navigateUp() },
viewModel = hiltViewModel(parentEntry),
)
}
NodeDetailRoute.entries.forEach { nodeDetailRoute ->
composable(nodeDetailRoute.route::class) { backStackEntry ->
val parentEntry = remember(backStackEntry) {
val parentRoute = backStackEntry.destination.parent!!.route!!
navController.getBackStackEntry(parentRoute)
}
val parentEntry =
remember(backStackEntry) {
val parentRoute = backStackEntry.destination.parent!!.route!!
navController.getBackStackEntry(parentRoute)
}
when (nodeDetailRoute) {
NodeDetailRoute.DEVICE -> DeviceMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.NODE_MAP -> NodeMapScreen(hiltViewModel(parentEntry))
NodeDetailRoute.NODE_MAP -> NodeMapScreen(uiViewModel, hiltViewModel(parentEntry))
NodeDetailRoute.POSITION_LOG -> PositionLogScreen(hiltViewModel(parentEntry))
NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(
hiltViewModel(
parentEntry
)
)
NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(
viewModel = hiltViewModel(
parentEntry
)
)
NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(viewModel = hiltViewModel(parentEntry))
NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry))
NodeDetailRoute.HOST -> HostMetricsLogScreen(hiltViewModel(parentEntry))
@ -173,11 +134,7 @@ fun NavGraphBuilder.nodeDetailGraph(
}
}
enum class NodeDetailRoute(
@StringRes val title: Int,
val route: Route,
val icon: ImageVector?,
) {
enum class NodeDetailRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?) {
DEVICE(R.string.device, NodeDetailRoutes.DeviceMetrics, Icons.Default.Router),
NODE_MAP(R.string.node_map, NodeDetailRoutes.NodeMap, Icons.Default.LocationOn),
POSITION_LOG(R.string.position_log, NodeDetailRoutes.PositionLog, Icons.Default.LocationOn),

View file

@ -178,12 +178,12 @@ fun ConnectionsScreen(
val isGpsDisabled = context.gpsDisabled()
LaunchedEffect(isGpsDisabled) {
if (isGpsDisabled) {
uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
}
}
LaunchedEffect(bluetoothEnabled) {
if (!bluetoothEnabled) {
uiViewModel.showSnackbar(context.getString(R.string.bluetooth_disabled))
uiViewModel.showSnackBar(context.getString(R.string.bluetooth_disabled))
}
}
// when scanning is true - wait 10000ms and then stop scanning
@ -234,7 +234,7 @@ fun ConnectionsScreen(
if (!isGpsDisabled) {
uiViewModel.meshService?.startProvideLocation()
} else {
uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
}
} else {
// Request permissions if not granted and user wants to provide location
@ -575,7 +575,7 @@ fun ConnectionsScreen(
onClick = {
showReportBugDialog = false
reportError("Clicked Report A Bug")
uiViewModel.showSnackbar("Bug report sent!")
uiViewModel.showSnackBar("Bug report sent!")
},
) {
Text(stringResource(R.string.report))
@ -619,6 +619,7 @@ private enum class DeviceType {
NO_DEVICE_SELECTED -> null
else -> null
}
else -> null
}
}

View file

@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.model.Node
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
@Suppress("TooManyFunctions")
abstract class BaseMapViewModel(
protected val preferences: SharedPreferences,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
) : ViewModel() {
val nodes: StateFlow<List<Node>> =
nodeRepository
.getNodes()
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val waypoints: StateFlow<Map<Int, Packet>> =
packetRepository
.getWaypoints()
.mapLatest { list ->
list
.associateBy { packet -> packet.data.waypoint!!.id }
.filterValues {
it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000
}
}
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyMap())
private val showOnlyFavorites = MutableStateFlow(preferences.getBoolean("only-favorites", false))
private val showWaypointsOnMap = MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true))
private val showPrecisionCircleOnMap =
MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true))
fun toggleOnlyFavorites() {
val current = showOnlyFavorites.value
preferences.edit { putBoolean("only-favorites", !current) }
showOnlyFavorites.value = !current
}
fun toggleShowWaypointsOnMap() {
val current = showWaypointsOnMap.value
preferences.edit { putBoolean("show-waypoints-on-map", !current) }
showWaypointsOnMap.value = !current
}
fun toggleShowPrecisionCircleOnMap() {
val current = showPrecisionCircleOnMap.value
preferences.edit { putBoolean("show-precision-circle-on-map", !current) }
showPrecisionCircleOnMap.value = !current
}
data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean)
val mapFilterStateFlow: StateFlow<MapFilterState> =
combine(showOnlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) {
favoritesOnly,
showWaypoints,
showPrecisionCircle,
->
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue =
MapFilterState(showOnlyFavorites.value, showWaypointsOnMap.value, showPrecisionCircleOnMap.value),
)
}

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.map
const val MAP_STYLE_ID = "map_style_id"

View file

@ -1,788 +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.ui.map
import android.Manifest // Added for Accompanist
import android.content.Context
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lens
import androidx.compose.material.icons.filled.LocationDisabled
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.MyLocation
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.android.hasGps
import com.geeksville.mesh.copy
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.map.CustomTileSource
import com.geeksville.mesh.model.map.MarkerWithLabel
import com.geeksville.mesh.model.map.clustering.RadiusMarkerClusterer
import com.geeksville.mesh.ui.map.components.CacheLayout
import com.geeksville.mesh.ui.map.components.DownloadButton
import com.geeksville.mesh.ui.map.components.EditWaypointDialog
import com.geeksville.mesh.ui.map.components.MapButton
import com.geeksville.mesh.util.SqlTileWriterExt
import com.geeksville.mesh.util.addCopyright
import com.geeksville.mesh.util.addScaleBarOverlay
import com.geeksville.mesh.util.createLatLongGrid
import com.geeksville.mesh.util.formatAgo
import com.geeksville.mesh.util.zoomIn
import com.geeksville.mesh.waypoint
import com.google.accompanist.permissions.ExperimentalPermissionsApi // Added for Accompanist
import com.google.accompanist.permissions.rememberMultiplePermissionsState // Added for Accompanist
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.cachemanager.CacheManager
import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
import java.text.DateFormat
@Composable
private fun MapView.UpdateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
nodeClusterer: RadiusMarkerClusterer,
) {
debug("Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints")
overlays.removeAll { it is MarkerWithLabel }
// overlays.addAll(nodeMarkers + waypointMarkers)
overlays.addAll(waypointMarkers)
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
nodeClusterer.invalidate()
}
// private fun addWeatherLayer() {
// if (map.tileProvider.tileSource.name()
// .equals(CustomTileSource.getTileSource("ESRI World TOPO").name())
// ) {
// val layer = TilesOverlay(
// MapTileProviderBasic(
// activity,
// CustomTileSource.OPENWEATHER_RADAR
// ), context
// )
// layer.loadingBackgroundColor = Color.TRANSPARENT
// layer.loadingLineColor = Color.TRANSPARENT
// map.overlayManager.add(layer)
// }
// }
private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) =
object : CacheManager.CacheManagerCallback {
override fun onTaskComplete() {
onTaskComplete()
}
override fun onTaskFailed(errors: Int) {
onTaskFailed(errors)
}
override fun updateProgress(progress: Int, currentZoomLevel: Int, zoomMin: Int, zoomMax: Int) {
// NOOP since we are using the build in UI
}
override fun downloadStarted() {
// NOOP since we are using the build in UI
}
override fun setPossibleTilesInArea(total: Int) {
// NOOP since we are using the build in UI
}
}
private fun Context.purgeTileSource(onResult: (String) -> Unit) {
val cache = SqlTileWriterExt()
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.map_tile_source)
val sources = cache.sources
val sourceList = mutableListOf<String>()
for (i in sources.indices) {
sourceList.add(sources[i].source as String)
}
val selected: BooleanArray? = null
val selectedList = mutableListOf<Int>()
builder.setMultiChoiceItems(sourceList.toTypedArray(), selected) { _, i, b ->
if (b) {
selectedList.add(i)
} else {
selectedList.remove(i)
}
}
builder.setPositiveButton(R.string.clear) { _, _ ->
for (x in selectedList) {
val item = sources[x]
val b = cache.purgeCache(item.source)
onResult(
if (b) {
getString(R.string.map_purge_success, item.source)
} else {
getString(R.string.map_purge_fail)
},
)
}
}
builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
builder.show()
}
/**
* Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user
* interactions for map manipulation, filtering, and offline caching.
*
* @param model The [UIViewModel] providing data and state for the map.
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Unit) {
var mapFilterExpanded by remember { mutableStateOf(false) }
val mapFilterState by model.mapFilterStateFlow.collectAsState()
var cacheEstimate by remember { mutableStateOf("") }
var zoomLevelMin by remember { mutableDoubleStateOf(0.0) }
var zoomLevelMax by remember { mutableDoubleStateOf(0.0) }
var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) }
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
var showCurrentCacheInfo by remember { mutableStateOf(false) }
val context = LocalContext.current
val density = LocalDensity.current
val haptic = LocalHapticFeedback.current
fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val hasGps = remember { context.hasGps() }
// Accompanist permissions state for location
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
fun loadOnlineTileSourceBase(): ITileSource {
val id = model.mapStyleId
debug("mapStyleId from prefs: $id")
return CustomTileSource.getTileSource(id).also {
zoomLevelMax = it.maximumZoomLevel.toDouble()
showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false
}
}
val initialCameraView = remember {
val nodes = model.nodeList.value
val nodesWithPosition = nodes.filter { it.validPosition != null }
val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) }
BoundingBox.fromGeoPoints(geoPoints)
}
val map = rememberMapViewWithLifecycle(initialCameraView, loadOnlineTileSourceBase())
val nodeClusterer = remember { RadiusMarkerClusterer(context) }
fun MapView.toggleMyLocation() {
if (context.gpsDisabled()) {
debug("Telling user we need location turned on for MyLocationNewOverlay")
model.showSnackbar(R.string.location_disabled)
return
}
debug("user clicked MyLocationNewOverlay ${myLocationOverlay == null}")
if (myLocationOverlay == null) {
myLocationOverlay =
MyLocationNewOverlay(this).apply {
enableMyLocation()
enableFollowLocation()
getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot_24)?.let {
setPersonIcon(it)
setPersonAnchor(0.5f, 0.5f)
}
getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation_24)?.let {
setDirectionIcon(it)
setDirectionAnchor(0.5f, 0.5f)
}
}
overlays.add(myLocationOverlay)
} else {
myLocationOverlay?.apply {
disableMyLocation()
disableFollowLocation()
}
overlays.remove(myLocationOverlay)
myLocationOverlay = null
}
}
// Effect to toggle MyLocation after permission is granted
LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
map.toggleMyLocation()
triggerLocationToggleAfterPermission = false
}
}
val nodes by model.nodeList.collectAsStateWithLifecycle()
val waypoints by model.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
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
return@mapNotNull null
}
val (p, u) = node.position to node.user
val nodePosition = GeoPoint(node.latitude, node.longitude)
MarkerWithLabel(mapView = this, label = "${u.shortName} ${formatAgo(p.time)}").apply {
id = u.id
title = u.longName
snippet =
context.getString(
R.string.map_node_popup_details,
node.gpsString(gpsFormat),
formatAgo(node.lastHeard),
formatAgo(p.time),
if (node.batteryStr != "") node.batteryStr else "?",
)
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
subDescription = context.getString(R.string.map_subDescription, ourNode.bearing(node), dist)
}
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
position = nodePosition
icon = markerIcon
setNodeColors(node.colors)
if (!mapFilterStateValue.showPrecisionCircle) {
setPrecisionBits(0)
} else {
setPrecisionBits(p.precisionBits)
}
setOnLongClickListener {
navigateToNodeDetails(node.num)
true
}
}
}
}
fun showDeleteMarkerDialog(waypoint: Waypoint) {
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(R.string.waypoint_delete)
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)
}
if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.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)
}
}
val dialog = builder.show()
for (
button in
setOf(
androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL,
androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE,
androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE,
)
) {
with(dialog.getButton(button)) {
textSize = 12F
isAllCaps = false
}
}
}
fun showMarkerLongPressDialog(id: Int) {
performHapticFeedback()
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()) {
showEditWaypointDialog = waypoint
} else {
showDeleteMarkerDialog(waypoint)
}
}
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) {
context.getString(R.string.you)
} else {
model.getUser(id).longName
}
@Composable
@Suppress("MagicNumber")
fun MapView.onWaypointChanged(waypoints: Collection<Packet>): List<MarkerWithLabel> {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
return waypoints.mapNotNull { waypoint ->
val pt = waypoint.data.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if (pt.lockedTo != 0) "\uD83D\uDD12" else ""
val time = dateFormat.format(waypoint.received_time)
val label = pt.name + " " + formatAgo((waypoint.received_time / 1000).toInt())
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
val timeLeft = pt.expire * 1000L - System.currentTimeMillis()
val expireTimeStr =
when {
pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
timeLeft <= 0 -> "Expired"
timeLeft < 60_000 -> "${timeLeft / 1000} seconds"
timeLeft < 3_600_000 -> "${timeLeft / 60_000} minute${if (timeLeft / 60_000 != 1L) "s" else ""}"
timeLeft < 86_400_000 -> {
val hours = (timeLeft / 3_600_000).toInt()
val minutes = ((timeLeft % 3_600_000) / 60_000).toInt()
if (minutes >= 30) {
"${hours + 1} hour${if (hours + 1 != 1) "s" else ""}"
} else if (minutes > 0) {
"$hours hour${if (hours != 1) "s" else ""}, $minutes minute${if (minutes != 1) "s" else ""}"
} else {
"$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 {
id = "${pt.id}"
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
snippet = "[$time] ${pt.description} " + stringResource(R.string.expires) + ": $expireTimeStr"
position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7)
setVisible(false) // This seems to be always false, was this intended?
setOnLongClickListener {
showMarkerLongPressDialog(pt.id)
true
}
}
}
}
val isConnected = model.isConnectedStateFlow.collectAsStateWithLifecycle(false)
LaunchedEffect(showCurrentCacheInfo) {
if (!showCurrentCacheInfo) return@LaunchedEffect
model.showSnackbar(R.string.calculating)
val cacheManager = CacheManager(map)
val cacheCapacity = cacheManager.cacheCapacity()
val currentCacheUsage = cacheManager.currentCacheUsage()
val mapCacheInfoText =
context.getString(
R.string.map_cache_info,
cacheCapacity / (1024.0 * 1024.0),
currentCacheUsage / (1024.0 * 1024.0),
)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.map_cache_manager)
.setMessage(mapCacheInfoText)
.setPositiveButton(R.string.close) { dialog, _ ->
showCurrentCacheInfo = false
dialog.dismiss()
}
.show()
}
val mapEventsReceiver =
object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
InfoWindow.closeAllInfoWindowsOn(map)
return true
}
override fun longPressHelper(p: GeoPoint): Boolean {
performHapticFeedback()
val enabled = isConnected.value && downloadRegionBoundingBox == null
if (enabled) {
showEditWaypointDialog = waypoint {
latitudeI = (p.latitude * 1e7).toInt()
longitudeI = (p.longitude * 1e7).toInt()
}
}
return true
}
}
fun MapView.drawOverlays() {
if (overlays.none { it is MapEventsOverlay }) {
overlays.add(0, MapEventsOverlay(mapEventsReceiver))
}
if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) {
overlays.add(myLocationOverlay)
}
if (overlays.none { it is RadiusMarkerClusterer }) {
overlays.add(nodeClusterer)
}
addCopyright()
addScaleBarOverlay(density)
createLatLongGrid(false)
invalidate()
}
with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) }
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
val zoomFactor = 1.3
zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax)
downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor)
val polygon =
Polygon().apply {
points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) }
}
overlays.add(polygon)
invalidate()
val tileCount: Int =
CacheManager(this)
.possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt())
cacheEstimate = context.getString(R.string.map_cache_tiles, tileCount)
}
val boxOverlayListener =
object : MapListener {
override fun onScroll(event: ScrollEvent): Boolean {
if (downloadRegionBoundingBox != null) {
event.source.generateBoxOverlay()
}
return true
}
override fun onZoom(event: ZoomEvent): Boolean = false
}
fun startDownload() {
val boundingBox = downloadRegionBoundingBox ?: return
try {
val outputName = buildString {
append(Configuration.getInstance().osmdroidBasePath.absolutePath)
append(File.separator)
append("mainFile.sqlite")
}
val writer = SqliteArchiveTileWriter(outputName)
val cacheManager = CacheManager(map, writer)
cacheManager.downloadAreaAsync(
context,
boundingBox,
zoomLevelMin.toInt(),
zoomLevelMax.toInt(),
cacheManagerCallback(
onTaskComplete = {
model.showSnackbar(R.string.map_download_complete)
writer.onDetach()
},
onTaskFailed = { errors ->
model.showSnackbar(context.getString(R.string.map_download_errors, errors))
writer.onDetach()
},
),
)
} catch (ex: TileSourcePolicyException) {
debug("Tile source does not allow archiving: ${ex.message}")
} catch (ex: Exception) {
debug("Tile source exception: ${ex.message}")
}
}
fun showMapStyleDialog() {
val builder = MaterialAlertDialogBuilder(context)
val mapStyles: Array<CharSequence> = CustomTileSource.mTileSources.values.toTypedArray()
val mapStyleInt = model.mapStyleId
builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which ->
debug("Set mapStyleId pref to $which")
model.mapStyleId = which
dialog.dismiss()
map.setTileSource(loadOnlineTileSourceBase())
}
val dialog = builder.create()
dialog.show()
}
fun Context.showCacheManagerDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.map_offline_manager)
.setItems(
arrayOf<CharSequence>(
getString(R.string.map_cache_size),
getString(R.string.map_download_region),
getString(R.string.map_clear_tiles),
getString(R.string.cancel),
),
) { dialog, which ->
when (which) {
0 -> showCurrentCacheInfo = true
1 -> {
map.generateBoxOverlay()
dialog.dismiss()
}
2 -> purgeTileSource { model.showSnackbar(it) }
else -> dialog.dismiss()
}
}
.show()
}
Scaffold(
floatingActionButton = {
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { context.showCacheManagerDialog() }
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
AndroidView(
factory = {
map.apply {
setDestroyMode(false)
addMapListener(boxOverlayListener)
}
},
modifier = Modifier.fillMaxSize(),
update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict
)
if (downloadRegionBoundingBox != null) {
CacheLayout(
cacheEstimate = cacheEstimate,
onExecuteJob = { startDownload() },
onCancelDownload = {
downloadRegionBoundingBox = null
map.overlays.removeAll { it is Polygon }
map.invalidate()
},
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
Column(
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
MapButton(
onClick = ::showMapStyleDialog,
icon = Icons.Outlined.Layers,
contentDescription = R.string.map_style_selection,
)
Box(modifier = Modifier) {
MapButton(
onClick = { mapFilterExpanded = true },
icon = Icons.Outlined.Tune,
contentDescription = R.string.map_filter,
)
DropdownMenu(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.string.only_favorites),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { model.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { model.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.string.show_waypoints),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { model.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { model.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.string.show_precision_circle),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { model.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { model.toggleShowPrecisionCircleOnMap() },
)
}
}
if (hasGps) {
MapButton(
icon =
if (myLocationOverlay == null) {
Icons.Outlined.MyLocation
} else {
Icons.Default.LocationDisabled
},
contentDescription = stringResource(R.string.toggle_my_position),
) {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
}
}
}
}
}
}
if (showEditWaypointDialog != null) {
EditWaypointDialog(
waypoint = showEditWaypointDialog ?: return, // Safe call
onSendClicked = { waypoint ->
debug("User clicked send waypoint ${waypoint.id}")
showEditWaypointDialog = null
model.sendWaypoint(
waypoint.copy {
if (id == 0) id = model.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
if (waypoint.icon == 0) icon = 128205
},
)
},
onDeleteClicked = { waypoint ->
debug("User clicked delete waypoint ${waypoint.id}")
showEditWaypointDialog = null
showDeleteMarkerDialog(waypoint)
},
onDismissRequest = {
debug("User clicked cancel marker edit dialog")
showEditWaypointDialog = null
},
)
}
}

View file

@ -1,173 +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.ui.map
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.android.BuildUtils.errormsg
import com.geeksville.mesh.util.requiredZoomLevel
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
@SuppressLint("WakelockTimeout")
private fun PowerManager.WakeLock.safeAcquire() {
if (!isHeld) {
try {
acquire()
} catch (e: SecurityException) {
errormsg("WakeLock permission exception: ${e.message}")
} catch (e: IllegalStateException) {
errormsg("WakeLock acquire() exception: ${e.message}")
}
}
}
private fun PowerManager.WakeLock.safeRelease() {
if (isHeld) {
try {
release()
} catch (e: IllegalStateException) {
errormsg("WakeLock release() exception: ${e.message}")
}
}
}
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
@Suppress("MagicNumber")
@Composable
internal fun rememberMapViewWithLifecycle(
box: BoundingBox,
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
): MapView {
val zoom =
if (box.requiredZoomLevel().isFinite()) {
(box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL)
} else {
DEFAULT_ZOOM_LEVEL
}
val center = GeoPoint(box.centerLatitude, box.centerLongitude)
return rememberMapViewWithLifecycle(zoom, center, tileSource)
}
@Suppress("LongMethod")
@Composable
internal fun rememberMapViewWithLifecycle(
zoomLevel: Double = MIN_ZOOM_LEVEL,
mapCenter: GeoPoint = GeoPoint(0.0, 0.0),
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
): MapView {
var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) }
var savedCenter by
rememberSaveable(
stateSaver =
Saver(
save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) },
restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) },
),
) {
mutableStateOf(mapCenter)
}
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
clipToOutline = true
// Required to get online tiles
Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID
setTileSource(tileSource)
isVerticalMapRepetitionEnabled = false // disables map repetition
setMultiTouchControls(true)
val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map
setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0)
// scales the map tiles to the display density of the screen
isTilesScaledToDpi = true
// sets the minimum zoom level (the furthest out you can zoom)
minZoomLevel = MIN_ZOOM_LEVEL
maxZoomLevel = MAX_ZOOM_LEVEL
// Disables default +/- button for zooming
zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT)
controller.setZoom(savedZoom)
controller.setCenter(savedCenter)
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@Suppress("DEPRECATION")
val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
wakeLock.safeAcquire()
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
wakeLock.safeRelease()
mapView.onPause()
}
Lifecycle.Event.ON_RESUME -> {
wakeLock.safeAcquire()
mapView.onResume()
}
Lifecycle.Event.ON_STOP -> {
savedCenter = mapView.projection.currentCenter
savedZoom = mapView.zoomLevelDouble
}
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
wakeLock.safeRelease()
}
}
return mapView
}

View file

@ -1,108 +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.ui.map.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Button
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun CacheLayout(
cacheEstimate: String,
onExecuteJob: () -> Unit,
onCancelDownload: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.background(color = MaterialTheme.colorScheme.background)
.padding(8.dp),
) {
Text(
text = stringResource(id = R.string.map_select_download_region),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.map_tile_download_estimate) + " " + cacheEstimate,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
FlowRow(
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 = onExecuteJob,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(id = R.string.map_start_download),
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun CacheLayoutPreview() {
CacheLayout(
cacheEstimate = "100 tiles",
onExecuteJob = { },
onCancelDownload = { },
)
}

View file

@ -1,69 +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.ui.map.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
@Composable
internal fun DownloadButton(
enabled: Boolean,
onClick: () -> Unit,
) {
AnimatedVisibility(
visible = enabled,
enter = slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
),
exit = slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
)
) {
FloatingActionButton(
onClick = onClick,
contentColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.map_download_region),
modifier = Modifier.scale(1.25f),
)
}
}
}
//@Preview(showBackground = true)
//@Composable
//private fun DownloadButtonPreview() {
// DownloadButton(true, onClick = {})
//}

View file

@ -1,344 +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.ui.map.components
import android.app.DatePickerDialog
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.tooling.preview.Preview
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.EditTextPreference
import com.geeksville.mesh.ui.common.components.EmojiPickerDialog
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.waypoint
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal 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
@Suppress("MagicNumber")
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
// Get current context for dialogs
val context = LocalContext.current
val calendar = Calendar.getInstance()
val currentTime = System.currentTimeMillis()
calendar.timeInMillis = currentTime
@Suppress("MagicNumber")
calendar.add(Calendar.HOUR_OF_DAY, 8)
// Current time for initializing pickers
val year = calendar.get(Calendar.YEAR)
val month = calendar.get(Calendar.MONTH)
val day = calendar.get(Calendar.DAY_OF_MONTH)
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE)
// 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)
}
// 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)
}
// State to hold selected date and time
var selectedDate by remember { mutableStateOf(dateFormat.format(calendar.time)) }
var selectedTime by remember { mutableStateOf(timeFormat.format(calendar.time)) }
var epochTime by remember { mutableStateOf<Long?>(null) }
if (!showEmojiPickerView) {
AlertDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
EditTextPreference(
title = stringResource(R.string.name),
value = waypointInput.name,
maxSize = 29,
enabled = true,
isError = false,
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)
.padding(4.dp),
fontSize = 24.sp,
color = Color.Unspecified.copy(alpha = 1f),
)
}
},
)
EditTextPreference(
title = stringResource(R.string.description),
value = waypointInput.description,
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 } }
)
Row(
modifier = Modifier
.fillMaxWidth()
.size(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
imageVector = Icons.Default.Lock,
contentDescription = stringResource(R.string.locked),
)
Text(stringResource(R.string.locked))
Switch(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
checked = waypointInput.lockedTo != 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 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
)
Row(
modifier = Modifier
.fillMaxWidth()
.size(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
imageVector = Icons.Default.CalendarMonth,
contentDescription = stringResource(R.string.expires),
)
Text(stringResource(R.string.expires))
Switch(
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()
}
if (isChecked) {
selectedDate = dateFormat.format(calendar.time)
selectedTime = timeFormat.format(calendar.time)
} else {
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,
)
}
}
}
} },
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)) }
if (waypoint.id != 0) {
Button(
modifier = modifier.weight(1f),
onClick = { onDeleteClicked(waypointInput) },
enabled = waypointInput.name.isNotEmpty(),
) { Text(stringResource(R.string.delete)) }
}
Button(
modifier = modifier.weight(1f),
onClick = { onSendClicked(waypointInput) },
enabled = true,
) { Text(stringResource(R.string.send)) }
}
},
)
} else {
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
showEmojiPickerView = false
waypointInput = waypointInput.copy { icon = it.codePointAt(0) }
}
}
}
@Preview(showBackground = true)
@Composable
@Suppress("MagicNumber")
private fun EditWaypointFormPreview() {
AppTheme {
EditWaypointDialog(
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 = { },
)
}
}

View file

@ -1,78 +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.ui.map.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun MapButton(
icon: ImageVector,
@StringRes contentDescription: Int,
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) {
MapButton(
icon = icon,
contentDescription = stringResource(contentDescription),
modifier = modifier,
onClick = onClick,
)
}
@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),
)
}
}
@PreviewLightDark
@Composable
private fun MapButtonPreview() {
AppTheme {
MapButton(
icon = Icons.Outlined.Layers,
contentDescription = R.string.map_style_selection,
)
}
}

View file

@ -102,17 +102,12 @@ private fun HeaderItem(compactWidth: Boolean) {
}
}
private const val DEG_D = 1e-7
private const val HEADING_DEG = 1e-5
const val DEG_D = 1e-7
const val HEADING_DEG = 1e-5
private const val SECONDS_TO_MILLIS = 1000L
@Composable
private fun PositionItem(
compactWidth: Boolean,
position: MeshProtos.Position,
dateFormat: DateFormat,
system: DisplayUnits,
) {
fun PositionItem(compactWidth: Boolean, position: MeshProtos.Position, dateFormat: DateFormat, system: DisplayUnits) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@ -130,7 +125,7 @@ private fun PositionItem(
}
@Composable
private fun formatPositionTime(position: MeshProtos.Position, dateFormat: DateFormat): String {
fun formatPositionTime(position: MeshProtos.Position, dateFormat: DateFormat): String {
val currentTime = System.currentTimeMillis()
val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds
val isOlderThanSixMonths = position.time * SECONDS_TO_MILLIS < sixMonthsAgo

View file

@ -1,62 +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.ui.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
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.ui.map.rememberMapViewWithLifecycle
import com.geeksville.mesh.util.addCopyright
import com.geeksville.mesh.util.addPolyline
import com.geeksville.mesh.util.addPositionMarkers
import com.geeksville.mesh.util.addScaleBarOverlay
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
private const val DegD = 1e-7
@Composable
fun NodeMapScreen(
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 cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
val mapView = rememberMapViewWithLifecycle(cameraView, viewModel.tileSource)
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(state.positionLogs) {}
}
)
}

View file

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

View file

@ -39,10 +39,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.HyperlinkBlue
@ -52,91 +49,61 @@ import java.net.URLEncoder
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LinkedCoordinates(
modifier: Modifier = Modifier,
latitude: Double,
longitude: Double,
format: Int,
nodeName: String,
) {
fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude: Double, nodeName: String) {
val context = LocalContext.current
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val style = SpanStyle(
color = HyperlinkBlue,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
textDecoration = TextDecoration.Underline
)
val style =
SpanStyle(
color = HyperlinkBlue,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
textDecoration = TextDecoration.Underline,
)
val annotatedString = rememberAnnotatedString(latitude, longitude, format, nodeName, style)
val annotatedString = rememberAnnotatedString(latitude, longitude, nodeName, style)
Text(
modifier = modifier.combinedClickable(
onClick = {
handleClick(context, annotatedString)
},
modifier =
modifier.combinedClickable(
onClick = { handleClick(context, annotatedString) },
onLongClick = {
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText("", annotatedString)
)
)
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", annotatedString)))
debug("Copied to clipboard")
}
}
},
),
text = annotatedString
text = annotatedString,
)
}
@Composable
private fun rememberAnnotatedString(
latitude: Double,
longitude: Double,
format: Int,
nodeName: String,
style: SpanStyle
) = buildAnnotatedString {
pushStringAnnotation(
tag = "gps",
annotation = "geo:0,0?q=$latitude,$longitude&z=17&label=${
URLEncoder.encode(nodeName, "utf-8")
}"
)
withStyle(style = style) {
val gpsString = when (format) {
GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
else -> GPSFormat.toDEC(latitude, longitude)
private fun rememberAnnotatedString(latitude: Double, longitude: Double, nodeName: String, style: SpanStyle) =
buildAnnotatedString {
pushStringAnnotation(
tag = "gps",
annotation =
"geo:0,0?q=$latitude,$longitude&z=17&label=${
URLEncoder.encode(nodeName, "utf-8")
}",
)
withStyle(style = style) {
val gpsString = GPSFormat.toDec(latitude, longitude)
append(gpsString)
}
append(gpsString)
pop()
}
pop()
}
private fun handleClick(context: Context, annotatedString: AnnotatedString) {
annotatedString.getStringAnnotations(
tag = "gps",
start = 0,
end = annotatedString.length
).firstOrNull()?.let {
annotatedString.getStringAnnotations(tag = "gps", start = 0, end = annotatedString.length).firstOrNull()?.let {
val uri = it.item.toUri()
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val intent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
try {
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
Toast.makeText(
context,
"No application available to open this location!",
Toast.LENGTH_LONG
).show()
Toast.makeText(context, "No application available to open this location!", Toast.LENGTH_LONG).show()
}
} catch (ex: ActivityNotFoundException) {
debug("Failed to open geo intent: $ex")
@ -146,20 +113,6 @@ private fun handleClick(context: Context, annotatedString: AnnotatedString) {
@PreviewLightDark
@Composable
fun LinkedCoordinatesPreview(
@PreviewParameter(GPSFormatPreviewParameterProvider::class) format: Int
) {
AppTheme {
LinkedCoordinates(
latitude = 37.7749,
longitude = -122.4194,
format = format,
nodeName = "Test Node Name"
)
}
}
class GPSFormatPreviewParameterProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2)
fun LinkedCoordinatesPreview() {
AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") }
}

View file

@ -49,6 +49,7 @@ import com.geeksville.mesh.model.Node
@Composable
fun NodeChip(
modifier: Modifier = Modifier,
enabled: Boolean = true,
node: Node,
isThisNode: Boolean,
isConnected: Boolean,
@ -87,6 +88,7 @@ fun NodeChip(
modifier =
Modifier.matchParentSize()
.combinedClickable(
enabled = enabled,
onClick = { onAction(NodeMenuAction.MoreDetails(node)) },
onLongClick = { menuExpanded = true },
interactionSource = inputChipInteractionSource,

View file

@ -65,7 +65,6 @@ import com.geeksville.mesh.util.toDistanceString
fun NodeItem(
thisNode: Node?,
thatNode: Node,
gpsFormat: Int,
distanceUnits: Int,
tempInFahrenheit: Boolean,
modifier: Modifier = Modifier,
@ -79,76 +78,64 @@ fun NodeItem(
val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) }
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) }
val distance = remember(thisNode, thatNode) {
thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system)
}
val distance =
remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) }
val hwInfoString = when (val hwModel = thatNode.user.hwModel) {
MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
}
val roleName = if (thatNode.isUnknownUser) {
DeviceConfig.Role.UNRECOGNIZED.name
} else {
thatNode.user.role.name
}
val hwInfoString =
when (val hwModel = thatNode.user.hwModel) {
MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
}
val roleName =
if (thatNode.isUnknownUser) {
DeviceConfig.Role.UNRECOGNIZED.name
} else {
thatNode.user.role.name
}
val style = if (thatNode.isUnknownUser) {
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
} else {
LocalTextStyle.current
}
val style =
if (thatNode.isUnknownUser) {
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
} else {
LocalTextStyle.current
}
val cardColors = if (isThisNode) {
thisNode?.colors?.second
} else {
thatNode.colors.second
}?.let {
val containerColor = Color(it).copy(alpha = 0.2f)
CardDefaults.cardColors().copy(
containerColor = containerColor,
contentColor = contentColorFor(containerColor)
)
} ?: (CardDefaults.cardColors())
val cardColors =
if (isThisNode) {
thisNode?.colors?.second
} else {
thatNode.colors.second
}
?.let {
val containerColor = Color(it).copy(alpha = 0.2f)
CardDefaults.cardColors()
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
} ?: (CardDefaults.cardColors())
val (detailsShown, showDetails) = remember { mutableStateOf(expanded) }
val unmessageable = remember(thatNode) {
when {
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
else -> thatNode.user.role.isUnmessageableRole()
val unmessageable =
remember(thatNode) {
when {
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
else -> thatNode.user.role.isUnmessageableRole()
}
}
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.defaultMinSize(minHeight = 80.dp),
modifier =
modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).defaultMinSize(minHeight = 80.dp),
onClick = { showDetails(!detailsShown) },
colors = cardColors
colors = cardColors,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
NodeChip(
node = thatNode,
isThisNode = isThisNode,
isConnected = isConnected,
onAction = onAction,
)
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
NodeChip(node = thatNode, isThisNode = isThisNode, isConnected = isConnected, onAction = onAction)
NodeKeyStatusIcon(
hasPKC = thatNode.hasPKC,
mismatchKey = thatNode.mismatchKey,
publicKey = thatNode.user.publicKey,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(32.dp),
)
Text(
modifier = Modifier.weight(1f),
@ -157,34 +144,21 @@ fun NodeItem(
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
softWrap = true,
)
LastHeardInfo(
lastHeard = thatNode.lastHeard,
currentTimeMillis = currentTimeMillis
)
LastHeardInfo(lastHeard = thatNode.lastHeard, currentTimeMillis = currentTimeMillis)
NodeStatusIcons(
isThisNode = isThisNode,
isFavorite = isFavorite,
isUnmessageable = unmessageable,
isConnected = isConnected
isConnected = isConnected,
)
}
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (distance != null) {
Text(
text = distance,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Text(text = distance, fontSize = MaterialTheme.typography.labelLarge.fontSize)
} else {
Spacer(modifier = Modifier.width(16.dp))
}
BatteryInfo(
batteryLevel = thatNode.batteryLevel,
voltage = thatNode.voltage
)
BatteryInfo(batteryLevel = thatNode.batteryLevel, voltage = thatNode.voltage)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
@ -192,10 +166,7 @@ fun NodeItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
SignalInfo(
node = thatNode,
isThisNode = isThisNode
)
SignalInfo(node = thatNode, isThisNode = isThisNode)
thatNode.validPosition?.let { position ->
val satCount = position.satsInView
if (satCount > 0) {
@ -204,10 +175,7 @@ fun NodeItem(
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
val telemetryString = thatNode.getTelemetryString(tempInFahrenheit)
if (telemetryString.isNotEmpty()) {
Text(
@ -222,31 +190,24 @@ fun NodeItem(
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
thatNode.validPosition?.let {
LinkedCoordinates(
latitude = thatNode.latitude,
longitude = thatNode.longitude,
format = gpsFormat,
nodeName = longName
nodeName = longName,
)
}
thatNode.validPosition?.let { position ->
ElevationInfo(
altitude = position.altitude,
system = system,
suffix = stringResource(id = R.string.elevation_suffix)
suffix = stringResource(id = R.string.elevation_suffix),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier.weight(1f),
text = hwInfoString,
@ -279,50 +240,29 @@ fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last()
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
1,
0,
true,
currentTimeMillis = System.currentTimeMillis(),
)
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, currentTimeMillis = System.currentTimeMillis())
}
}
@Composable
@Preview(
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
fun NodeInfoPreview(
@PreviewParameter(NodePreviewParameterProvider::class)
thatNode: Node
) {
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
Column {
Text(
text = "Details Collapsed",
color = MaterialTheme.colorScheme.onBackground
)
Text(text = "Details Collapsed", color = MaterialTheme.colorScheme.onBackground)
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
gpsFormat = 0,
distanceUnits = 1,
tempInFahrenheit = true,
expanded = false,
currentTimeMillis = System.currentTimeMillis(),
)
Text(
text = "Details Shown",
color = MaterialTheme.colorScheme.onBackground
)
Text(text = "Details Shown", color = MaterialTheme.colorScheme.onBackground)
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
gpsFormat = 0,
distanceUnits = 1,
tempInFahrenheit = true,
expanded = true,

View file

@ -44,16 +44,11 @@ import com.geeksville.mesh.ui.common.components.SwitchPreference
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
@Composable
fun DisplayConfigScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
) {
fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
if (state.responseState.isWaiting()) {
PacketResponseStateDialog(
state = state.responseState,
onDismiss = viewModel::clearPacketResponse,
)
PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
}
DisplayConfigItemList(
@ -62,22 +57,17 @@ fun DisplayConfigScreen(
onSaveClicked = { displayInput ->
val config = config { display = displayInput }
viewModel.setConfig(config)
}
},
)
}
@Suppress("LongMethod")
@Composable
fun DisplayConfigItemList(
displayConfig: DisplayConfig,
enabled: Boolean,
onSaveClicked: (DisplayConfig) -> Unit,
) {
fun DisplayConfigItemList(displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -> Unit) {
val focusManager = LocalFocusManager.current
var displayInput by rememberSaveable { mutableStateOf(displayConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { PreferenceCategory(text = stringResource(R.string.display_config)) }
item {
@ -86,21 +76,10 @@ fun DisplayConfigItemList(
value = displayInput.screenOnSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } }
onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } },
)
}
item {
DropDownPreference(
title = stringResource(R.string.gps_coordinates_format),
enabled = enabled,
items = DisplayConfig.GpsCoordinateFormat.entries
.filter { it != DisplayConfig.GpsCoordinateFormat.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.gpsFormat,
onItemSelected = { displayInput = displayInput.copy { gpsFormat = it } }
)
}
item { HorizontalDivider() }
item {
@ -109,9 +88,7 @@ fun DisplayConfigItemList(
value = displayInput.autoScreenCarouselSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
displayInput = displayInput.copy { autoScreenCarouselSecs = it }
}
onValueChanged = { displayInput = displayInput.copy { autoScreenCarouselSecs = it } },
)
}
@ -120,7 +97,7 @@ fun DisplayConfigItemList(
title = stringResource(R.string.compass_north_top),
checked = displayInput.compassNorthTop,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } }
onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } },
)
}
item { HorizontalDivider() }
@ -130,7 +107,7 @@ fun DisplayConfigItemList(
title = stringResource(R.string.flip_screen),
checked = displayInput.flipScreen,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } }
onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } },
)
}
item { HorizontalDivider() }
@ -139,11 +116,12 @@ fun DisplayConfigItemList(
DropDownPreference(
title = stringResource(R.string.display_units),
enabled = enabled,
items = DisplayConfig.DisplayUnits.entries
items =
DisplayConfig.DisplayUnits.entries
.filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.units,
onItemSelected = { displayInput = displayInput.copy { units = it } }
onItemSelected = { displayInput = displayInput.copy { units = it } },
)
}
item { HorizontalDivider() }
@ -152,11 +130,12 @@ fun DisplayConfigItemList(
DropDownPreference(
title = stringResource(R.string.override_oled_auto_detect),
enabled = enabled,
items = DisplayConfig.OledType.entries
items =
DisplayConfig.OledType.entries
.filter { it != DisplayConfig.OledType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.oled,
onItemSelected = { displayInput = displayInput.copy { oled = it } }
onItemSelected = { displayInput = displayInput.copy { oled = it } },
)
}
item { HorizontalDivider() }
@ -165,11 +144,12 @@ fun DisplayConfigItemList(
DropDownPreference(
title = stringResource(R.string.display_mode),
enabled = enabled,
items = DisplayConfig.DisplayMode.entries
items =
DisplayConfig.DisplayMode.entries
.filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.displaymode,
onItemSelected = { displayInput = displayInput.copy { displaymode = it } }
onItemSelected = { displayInput = displayInput.copy { displaymode = it } },
)
}
item { HorizontalDivider() }
@ -179,7 +159,7 @@ fun DisplayConfigItemList(
title = stringResource(R.string.heading_bold),
checked = displayInput.headingBold,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { headingBold = it } }
onCheckedChange = { displayInput = displayInput.copy { headingBold = it } },
)
}
item { HorizontalDivider() }
@ -189,7 +169,7 @@ fun DisplayConfigItemList(
title = stringResource(R.string.wake_screen_on_tap_or_motion),
checked = displayInput.wakeOnTapOrMotion,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } }
onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } },
)
}
item { HorizontalDivider() }
@ -198,11 +178,12 @@ fun DisplayConfigItemList(
DropDownPreference(
title = stringResource(R.string.compass_orientation),
enabled = enabled,
items = DisplayConfig.CompassOrientation.entries
items =
DisplayConfig.CompassOrientation.entries
.filter { it != DisplayConfig.CompassOrientation.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.compassOrientation,
onItemSelected = { displayInput = displayInput.copy { compassOrientation = it } }
onItemSelected = { displayInput = displayInput.copy { compassOrientation = it } },
)
}
item { HorizontalDivider() }
@ -213,7 +194,7 @@ fun DisplayConfigItemList(
summary = stringResource(R.string.display_time_in_12h_format),
enabled = enabled,
checked = displayInput.use12HClock,
onCheckedChange = { displayInput = displayInput.copy { use12HClock = it } }
onCheckedChange = { displayInput = displayInput.copy { use12HClock = it } },
)
}
item { HorizontalDivider() }
@ -228,7 +209,7 @@ fun DisplayConfigItemList(
onSaveClicked = {
focusManager.clearFocus()
onSaveClicked(displayInput)
}
},
)
}
}
@ -237,9 +218,5 @@ fun DisplayConfigItemList(
@Preview(showBackground = true)
@Composable
private fun DisplayConfigPreview() {
DisplayConfigItemList(
displayConfig = DisplayConfig.getDefaultInstance(),
enabled = true,
onSaveClicked = { },
)
DisplayConfigItemList(displayConfig = DisplayConfig.getDefaultInstance(), enabled = true, onSaveClicked = {})
}

View file

@ -211,7 +211,7 @@ fun ChannelScreen(
channelSet = channels // Throw away user edits
// Tell the user to try again
viewModel.showSnackbar(R.string.cant_change_no_radio)
viewModel.showSnackBar(R.string.cant_change_no_radio)
}
}

View file

@ -72,6 +72,21 @@ fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMil
}
}
private const val MPS_TO_KMPH = 3.6f
private const val KM_TO_MILES = 0.621371f
fun Int.mpsToKmph(): Float {
// Convert meters per second to kilometers per hour
val kmph = this * MPS_TO_KMPH
return kmph
}
fun Int.mpsToMph(): Float {
// Convert meters per second to miles per hour
val mph = this * MPS_TO_KMPH * KM_TO_MILES
return mph
}
// Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() })
fun EditText.onEditorAction(actionId: Int, func: () -> Unit) {
setOnEditorActionListener { _, receivedActionId, _ ->

View file

@ -17,311 +17,64 @@
package com.geeksville.mesh.util
import com.geeksville.mesh.MeshProtos
import android.annotation.SuppressLint
import com.geeksville.mesh.Position
import mil.nga.grid.features.Point
import mil.nga.mgrs.MGRS
import mil.nga.mgrs.utm.UTM
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.abs
import kotlin.math.acos
import java.util.Locale
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.log2
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.PI
/*******************************************************************************
* Revive some of my old Gaggle source code...
*
* GNU Public License, version 2
* All other distribution of Gaggle must conform to the terms of the GNU Public License, version 2. The full
* text of this license is included in the Gaggle source, see assets/manual/gpl-2.0.txt.
******************************************************************************/
import kotlin.math.sqrt
@SuppressLint("PropertyNaming")
object GPSFormat {
fun DEC(p: Position): String {
return String.format("%.5f %.5f", p.latitude, p.longitude).replace(",", ".")
}
fun DMS(p: Position): String {
val lat = degreesToDMS(p.latitude, true)
val lon = degreesToDMS(p.longitude, false)
fun string(a: Array<String>) = String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3])
return string(lat) + " " + string(lon)
}
fun UTM(p: Position): String {
val UTM = UTM.from(Point.point(p.longitude, p.latitude))
return String.format(
"%s%s %.6s %.7s",
UTM.zone,
UTM.toMGRS().band,
UTM.easting,
UTM.northing
)
}
fun MGRS(p: Position): String {
val MGRS = MGRS.from(Point.point(p.longitude, p.latitude))
return String.format(
"%s%s %s%s %05d %05d",
MGRS.zone,
MGRS.band,
MGRS.column,
MGRS.row,
MGRS.easting,
MGRS.northing
)
}
fun toDEC(latitude: Double, longitude: Double): String {
return "%.5f %.5f".format(latitude, longitude).replace(",", ".")
}
fun toDMS(latitude: Double, longitude: Double): String {
val lat = degreesToDMS(latitude, true)
val lon = degreesToDMS(longitude, false)
fun string(a: Array<String>) = "%s°%s'%.5s\"%s".format(a[0], a[1], a[2], a[3])
return string(lat) + " " + string(lon)
}
fun toUTM(latitude: Double, longitude: Double): String {
val UTM = UTM.from(Point.point(longitude, latitude))
return "%s%s %.6s %.7s".format(UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing)
}
fun toMGRS(latitude: Double, longitude: Double): String {
val MGRS = MGRS.from(Point.point(longitude, latitude))
return "%s%s %s%s %05d %05d".format(
MGRS.zone,
MGRS.band,
MGRS.column,
MGRS.row,
MGRS.easting,
MGRS.northing
)
}
fun toDec(latitude: Double, longitude: Double): String =
String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude)
}
/**
* Format as degrees, minutes, secs
*
* @param degIn
* @param isLatitude
* @return a string like 120deg
*/
fun degreesToDMS(
_degIn: Double,
isLatitude: Boolean
): Array<String> {
var degIn = _degIn
val isPos = degIn >= 0
val dirLetter =
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
degIn = abs(degIn)
val degOut = degIn.toInt()
val minutes = 60 * (degIn - degOut)
val minwhole = minutes.toInt()
val seconds = (minutes - minwhole) * 60
return arrayOf(
degOut.toString(), minwhole.toString(),
seconds.toString(),
dirLetter.toString()
)
}
private const val EARTH_RADIUS_METERS = 6371e3
fun degreesToDM(_degIn: Double, isLatitude: Boolean): Array<String> {
var degIn = _degIn
val isPos = degIn >= 0
val dirLetter =
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
degIn = abs(degIn)
val degOut = degIn.toInt()
val minutes = 60 * (degIn - degOut)
val seconds = 0
return arrayOf(
degOut.toString(), minutes.toString(),
seconds.toString(),
dirLetter.toString()
)
}
/** @return distance in meters along the surface of the earth (ish) */
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
val lat1 = Math.toRadians(latitudeA)
val lon1 = Math.toRadians(longitudeA)
val lat2 = Math.toRadians(latitudeB)
val lon2 = Math.toRadians(longitudeB)
fun degreesToD(_degIn: Double, isLatitude: Boolean): Array<String> {
var degIn = _degIn
val isPos = degIn >= 0
val dirLetter =
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
degIn = abs(degIn)
val degOut = degIn
val minutes = 0
val seconds = 0
return arrayOf(
degOut.toString(), minutes.toString(),
seconds.toString(),
dirLetter.toString()
)
}
val dLat = lat2 - lat1
val dLon = lon2 - lon1
/**
* A not super efficent mapping from a starting lat/long + a distance at a
* certain direction
*
* @param lat
* @param longitude
* @param distMeters
* @param theta
* in radians, 0 == north
* @return an array with lat and long
*/
fun addDistance(
lat: Double,
longitude: Double,
distMeters: Double,
theta: Double
): DoubleArray {
val dx = distMeters * sin(theta) // theta measured clockwise
// from due north
val dy = distMeters * cos(theta) // dx, dy same units as R
val dLong = dx / (111320 * cos(lat)) // dx, dy in meters
val dLat = dy / 110540 // result in degrees long/lat
return doubleArrayOf(lat + dLat, longitude + dLong)
}
val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2)
val c = 2 * asin(sqrt(a))
/**
* @return distance in meters along the surface of the earth (ish)
*/
fun latLongToMeter(
lat_a: Double,
lng_a: Double,
lat_b: Double,
lng_b: Double
): Double {
val pk = (180 / PI)
val a1 = lat_a / pk
val a2 = lng_a / pk
val b1 = lat_b / pk
val b2 = lng_b / pk
val t1 = cos(a1) * cos(a2) * cos(b1) * cos(b2)
val t2 = cos(a1) * sin(a2) * cos(b1) * sin(b2)
val t3 = sin(a1) * sin(b1)
var tt = acos(t1 + t2 + t3)
if (java.lang.Double.isNaN(tt)) tt = 0.0 // Must have been the same point?
return 6366000 * tt
return EARTH_RADIUS_METERS * c
}
// Same as above, but takes Mesh Position proto.
fun positionToMeter(a: MeshProtos.Position, b: MeshProtos.Position): Double {
return latLongToMeter(
a.latitudeI * 1e-7,
a.longitudeI * 1e-7,
b.latitudeI * 1e-7,
b.longitudeI * 1e-7
)
}
/**
* Convert degrees/mins/secs to a single double
*
* @param degrees
* @param minutes
* @param seconds
* @param isPostive
* @return
*/
fun DMSToDegrees(
degrees: Int,
minutes: Int,
seconds: Float,
isPostive: Boolean
): Double {
return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0)
}
fun DMSToDegrees(
degrees: Double,
minutes: Double,
seconds: Double,
isPostive: Boolean
): Double {
return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0)
}
fun positionToMeter(a: Position, b: Position): Double =
latLongToMeter(a.latitude * 1e-7, a.longitude * 1e-7, b.latitude * 1e-7, b.longitude * 1e-7)
/**
* Computes the bearing in degrees between two points on Earth.
*
* @param lat1
* Latitude of the first point
* @param lon1
* Longitude of the first point
* @param lat2
* Latitude of the second point
* @param lon2
* Longitude of the second point
* @return Bearing between the two points in degrees. A value of 0 means due
* north.
* @param lat1 Latitude of the first point
* @param lon1 Longitude of the first point
* @param lat2 Latitude of the second point
* @param lon2 Longitude of the second point
* @return Bearing between the two points in degrees. A value of 0 means due north.
*/
fun bearing(
lat1: Double,
lon1: Double,
lat2: Double,
lon2: Double
): Double {
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1Rad = Math.toRadians(lat1)
val lon1Rad = Math.toRadians(lon1)
val lat2Rad = Math.toRadians(lat2)
val deltaLonRad = Math.toRadians(lon2 - lon1)
val y = sin(deltaLonRad) * cos(lat2Rad)
val x = cos(lat1Rad) * sin(lat2Rad) - (sin(lat1Rad) * cos(lat2Rad) * cos(deltaLonRad))
return radToBearing(atan2(y, x))
}
/**
* Converts an angle in radians to degrees
*/
fun radToBearing(rad: Double): Double {
return (Math.toDegrees(rad) + 360) % 360
}
/**
* Calculates the zoom level required to fit the entire [BoundingBox] inside the map view.
* @return The zoom level as a Double value.
*/
fun BoundingBox.requiredZoomLevel(): Double {
val topLeft = GeoPoint(this.latNorth, this.lonWest)
val bottomRight = GeoPoint(this.latSouth, this.lonEast)
val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude))
val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude))
val requiredLatZoom = log2(360.0 / (latLonHeight / 111320))
val requiredLonZoom = log2(360.0 / (latLonWidth / 111320))
return maxOf(requiredLatZoom, requiredLonZoom) * 0.8
}
/**
* Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor].
* @return A new [BoundingBox] with added [zoomFactor]. Example:
* ```
* // Setting the zoom level directly using setZoom()
* map.setZoom(14.0)
* val boundingBoxZoom14 = map.boundingBox
*
* // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0)
* val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0)
* ```
*/
fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox {
val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2)
val latDiff = latNorth - latSouth
val lonDiff = lonEast - lonWest
val newLatDiff = latDiff / (2.0.pow(zoomFactor))
val newLonDiff = lonDiff / (2.0.pow(zoomFactor))
return BoundingBox(
center.latitude + newLatDiff / 2,
center.longitude + newLonDiff / 2,
center.latitude - newLatDiff / 2,
center.longitude - newLonDiff / 2
)
val lon2Rad = Math.toRadians(lon2)
val dLon = lon2Rad - lon1Rad
val y = sin(dLon) * cos(lat2Rad)
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
val bearing = Math.toDegrees(atan2(y, x))
return (bearing + 360) % 360
}

View file

@ -1,148 +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.util
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.CopyrightOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
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
*/
fun MapView.addCopyright() {
if (overlays.none { it is CopyrightOverlay }) {
val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return
val copyrightOverlay = CopyrightOverlay(context)
copyrightOverlay.setCopyrightNotice(copyrightNotice)
overlays.add(copyrightOverlay)
}
}
/**
* 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
}
latLongGridOverlay.textPaint = textPaint
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
latLongGridOverlay.setLineWidth(3.0f)
latLongGridOverlay.setLineColor(Color.GRAY)
overlays.add(latLongGridOverlay)
}
}
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())
}
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)
}
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> {
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
}
}
}
overlays.addAll(markers)
return markers
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.util
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
fun SharedPreferences.toggleBooleanPreference(
state: MutableStateFlow<Boolean>,
key: String,
onChanged: (Boolean) -> Unit = {},
) {
val newValue = !state.value
state.value = newValue
this.edit { putBoolean(key, newValue) }
onChanged(newValue)
}

View file

@ -1,98 +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.util
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.
*
*
* 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() + "")
)
}
/**
* gets all the tiles sources that we have tiles for in the cache database and their counts
*
* @return
*/
val sources: List<SourceCount>
get() {
val db = db
val ret: MutableList<SourceCount> = ArrayList()
if (db == null) {
return ret
}
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
)
while (cur.moveToNext()) {
val c = SourceCount()
c.source = cur.getString(0)
c.rowCount = cur.getLong(1)
c.sizeMin = cur.getLong(2)
c.sizeMax = cur.getLong(3)
c.sizeTotal = cur.getLong(4)
c.sizeAvg = c.sizeTotal / c.rowCount
ret.add(c)
}
} catch (e: Exception) {
catchException(e)
} finally {
cur?.close()
}
return ret
}
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
var sizeMin: Long = 0
var sizeMax: Long = 0
var sizeAvg: Long = 0
}
}