Meshtastic-Android/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt

600 lines
22 KiB
Kotlin
Raw Normal View History

2020-03-11 14:45:49 -07:00
package com.geeksville.mesh.ui
2022-09-22 08:35:33 -04:00
import android.app.AlertDialog
import android.content.Context
2022-08-24 11:08:39 -04:00
import android.content.SharedPreferences
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
2022-09-06 18:31:14 -03:00
import android.graphics.Rect
2020-03-30 11:56:59 -07:00
import android.os.Bundle
import android.util.Log
import android.view.*
2022-09-22 08:35:33 -04:00
import android.widget.*
2021-02-21 11:34:43 +08:00
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.NodeInfo
2020-03-11 18:13:44 -07:00
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
2022-09-22 08:38:24 -04:00
import com.geeksville.mesh.database.entity.Packet
2022-08-24 12:16:57 -04:00
import com.geeksville.mesh.databinding.MapViewBinding
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.map.CustomTileSource
2022-09-04 22:52:40 -03:00
import com.geeksville.mesh.util.formatAgo
2022-08-24 12:16:57 -04:00
import com.google.android.material.dialog.MaterialAlertDialogBuilder
2022-09-22 08:35:33 -04:00
import com.google.android.material.floatingactionbutton.FloatingActionButton
import dagger.hilt.android.AndroidEntryPoint
import org.osmdroid.api.IMapController
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
2022-09-22 08:35:33 -04:00
import org.osmdroid.tileprovider.cachemanager.CacheManager
import org.osmdroid.tileprovider.cachemanager.CacheManager.CacheManagerCallback
import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException
2022-08-23 20:27:14 -04:00
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
2022-09-22 08:35:33 -04:00
import org.osmdroid.views.overlay.*
import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2
import java.io.File
2022-09-22 08:35:33 -04:00
import kotlin.math.pow
2020-03-30 12:47:01 -07:00
@AndroidEntryPoint
2022-10-05 22:56:27 -04:00
class MapFragment : ScreenFragment("Map"), Logging, View.OnClickListener {
2020-04-07 12:13:50 -07:00
// UI Elements
2022-08-24 12:16:57 -04:00
private lateinit var binding: MapViewBinding
private lateinit var map: MapView
private lateinit var downloadBtn: FloatingActionButton
2022-09-22 08:35:33 -04:00
private lateinit var cacheEstimate: TextView
2022-10-05 22:56:27 -04:00
private lateinit var cancelDownload: Button
private lateinit var fiveMileButton: Button
private lateinit var tenMileButton: Button
private lateinit var fifteenMileButton: Button
private lateinit var executeJob: Button
2022-09-22 08:35:33 -04:00
private var downloadPrompt: AlertDialog? = null
private var alertDialog: AlertDialog? = null
// constants
private val defaultMinZoom = 1.5
private val nodeZoomLevel = 8.5
private val defaultZoomSpeed = 3000L
2022-09-22 08:35:33 -04:00
private val prefsName = "org.geeksville.osm.prefs"
private val mapStyleId = "map_style_id"
2022-09-08 17:46:19 -03:00
private var nodePositions = listOf<MarkerWithLabel>()
2022-09-22 08:38:24 -04:00
private var wayPoints = listOf<MarkerWithLabel>()
private val nodeLayer = 1
private val zoomLevelLowest = 16.0
private val zoomLevelMiddle = 14.0
private val zoomLevelHighest = 10.0
private var zoomLevelMin = 0.0
private var zoomLevelMax = 0.0
// Map Elements
private lateinit var mapController: IMapController
private lateinit var mPrefs: SharedPreferences
private lateinit var writer: SqliteArchiveTileWriter
private val model: UIViewModel by activityViewModels()
private lateinit var cacheManager: CacheManager
private lateinit var downloadRegionBoundingBox: BoundingBox
override fun onCreateView(
2022-09-22 08:35:33 -04:00
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
2022-08-24 23:15:48 -04:00
binding = MapViewBinding.inflate(inflater)
downloadBtn = binding.root.findViewById(R.id.downloadButton)
2022-10-05 22:56:27 -04:00
binding.cacheLayout.visibility = View.GONE
2022-08-24 12:16:57 -04:00
return binding.root
2022-08-24 11:08:39 -04:00
}
override fun onViewCreated(viewIn: View, savedInstanceState: Bundle?) {
super.onViewCreated(viewIn, savedInstanceState)
Configuration.getInstance().userAgentValue =
BuildConfig.APPLICATION_ID // Required to get online tiles
2022-08-24 23:15:48 -04:00
map = viewIn.findViewById(R.id.map)
2022-08-24 11:08:39 -04:00
mPrefs = context!!.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
setupMapProperties()
2022-09-03 07:39:37 -03:00
map.setTileSource(loadOnlineTileSourceBase())
2022-10-03 09:21:06 -04:00
renderDownloadButton()
map.let {
if (view != null) {
mapController = map.controller
2022-08-30 17:25:11 -03:00
binding.mapStyleButton.setOnClickListener {
chooseMapStyle()
}
model.nodeDB.nodes.value?.let { nodes ->
onNodesChanged(nodes.values)
drawOverlays()
}
2022-08-24 11:08:39 -04:00
}
// Any times nodes change update our map
model.nodeDB.nodes.observe(viewLifecycleOwner) { nodes ->
onNodesChanged(nodes.values)
drawOverlays()
}
model.waypoints.observe(viewLifecycleOwner) {
debug("New waypoints received: ${it.size}")
2022-09-22 08:38:24 -04:00
onWaypointChanged(it.values)
drawOverlays()
}
zoomToNodes(mapController)
}
downloadBtn.setOnClickListener(this)
2022-09-22 08:35:33 -04:00
}
override fun onClick(v: View) {
when (v.id) {
2022-10-05 22:56:27 -04:00
R.id.executeJob -> updateEstimate()
2022-09-22 08:35:33 -04:00
R.id.downloadButton -> showCacheManagerDialog()
R.id.box5miles -> generateBoxOverlay(zoomLevelLowest)
R.id.box10miles -> generateBoxOverlay(zoomLevelMiddle)
R.id.box15miles -> generateBoxOverlay(zoomLevelHighest)
2022-09-22 08:35:33 -04:00
}
}
private fun showCacheManagerDialog() {
val alertDialogBuilder = AlertDialog.Builder(
activity
)
// set title
alertDialogBuilder.setTitle("Cache Manager")
// set dialog message
alertDialogBuilder.setItems(
arrayOf<CharSequence>(
"Cache current size",
"Download Region",
"Clear Cache",
2022-09-22 08:35:33 -04:00
resources.getString(R.string.cancel)
)
) { dialog, which ->
when (which) {
0 -> showCurrentCacheInfo()
1 -> {
downloadJobAlert()
dialog.dismiss()
}
2 -> clearCache()
2022-09-22 08:35:33 -04:00
else -> dialog.dismiss()
}
}
// create alert dialog
alertDialog = alertDialogBuilder.create()
// show it
alertDialog!!.show()
}
private fun clearCache() {
}
2022-09-22 08:35:33 -04:00
private fun showCurrentCacheInfo() {
Toast.makeText(activity, "Calculating...", Toast.LENGTH_SHORT).show()
cacheManager = CacheManager(map) // Make sure CacheManager has latest from map
2022-09-22 08:35:33 -04:00
Thread {
val alertDialogBuilder = AlertDialog.Builder(
activity
)
// set title
alertDialogBuilder.setTitle("Cache Manager")
.setMessage(
"""
Cache Capacity (mb): ${cacheManager.cacheCapacity() * 2.0.pow(-20.0)}
Cache Usage (mb): ${cacheManager.currentCacheUsage() * 2.0.pow(-20.0)}
""".trimIndent()
)
// set dialog message
alertDialogBuilder.setItems(
arrayOf<CharSequence>(
resources.getString(R.string.cancel)
)
) { dialog, _ -> dialog.dismiss() }
activity!!.runOnUiThread { // show it
// create alert dialog
val alertDialog = alertDialogBuilder.create()
alertDialog.show()
}
}.start()
}
private fun downloadJobAlert() {
2022-10-05 22:56:27 -04:00
//prompt for input params .
binding.cacheLayout.visibility = View.VISIBLE
2022-09-22 08:35:33 -04:00
val builder = AlertDialog.Builder(activity)
fiveMileButton = binding.box5miles
2022-10-05 22:56:27 -04:00
fiveMileButton.setOnClickListener(this)
tenMileButton = binding.box10miles
2022-10-05 22:56:27 -04:00
tenMileButton.setOnClickListener(this)
fifteenMileButton = binding.box15miles
2022-10-05 22:56:27 -04:00
fifteenMileButton.setOnClickListener(this)
cacheEstimate = binding.cacheEstimate
generateBoxOverlay(zoomLevelLowest)
executeJob = binding.executeJob
2022-09-22 09:49:08 -04:00
executeJob.setOnClickListener(this)
2022-10-05 22:56:27 -04:00
//cancelDownload = binding.cacheLayout.findViewById(R.id.cancelDownload)
2022-09-22 09:49:08 -04:00
builder.setOnCancelListener {
cacheEstimate.text = ""
2022-10-05 22:56:27 -04:00
drawOverlays()
binding.cacheLayout.visibility = View.GONE
2022-09-22 08:35:33 -04:00
}
builder.setCancelable(true)
2022-10-05 22:56:27 -04:00
}
/**
* Creates Box overlay showing what area can be downloaded
*/
private fun generateBoxOverlay(zoomLevel: Double) {
map.invalidate()
map.setMultiTouchControls(false)
2022-10-05 22:56:27 -04:00
drawOverlays()
zoomLevelMax = zoomLevel
zoomLevelMin = map.minZoomLevel
mapController.setZoom(zoomLevel)
downloadRegionBoundingBox = map.boundingBox
val polygon = Polygon()
polygon.points = Polygon.pointsAsRect(downloadRegionBoundingBox) as MutableList<GeoPoint>
map.overlayManager.add(polygon)
map.invalidate()
mapController.setZoom(zoomLevel - 1.0)
cacheManager = CacheManager(map)
val tilecount: Int =
cacheManager.possibleTilesInArea(
downloadRegionBoundingBox,
zoomLevelMin.toInt(),
zoomLevelMax.toInt()
)
cacheEstimate.text = ("$tilecount tiles")
2022-09-22 08:35:33 -04:00
}
2022-09-22 08:35:33 -04:00
/**
* if true, start the job
* if false, just update the dialog box
*/
2022-10-05 22:56:27 -04:00
private fun updateEstimate() {
2022-09-22 08:35:33 -04:00
try {
2022-10-05 22:56:27 -04:00
if (this::downloadRegionBoundingBox.isInitialized) {
val outputName =
Configuration.getInstance().osmdroidBasePath.absolutePath + File.separator + "mainFile.sqlite" // TODO: Accept filename input param from user
writer = SqliteArchiveTileWriter(outputName)
//nesw
if (downloadPrompt != null) {
downloadPrompt!!.dismiss()
downloadPrompt = null
}
2022-10-05 22:56:27 -04:00
try {
cacheManager =
CacheManager(map, writer) // Make sure cacheManager has latest from map
2022-10-05 22:56:27 -04:00
} catch (ex: TileSourcePolicyException) {
Log.d("MapFragment", "Tilesource does not allow archiving: ${ex.message}")
2022-10-05 22:56:27 -04:00
return
2022-09-22 08:35:33 -04:00
}
//this triggers the download
downloadRegion(
downloadRegionBoundingBox,
zoomLevelMin.toInt(),
zoomLevelMax.toInt()
)
2022-09-22 08:35:33 -04:00
}
} catch (ex: Exception) {
ex.printStackTrace()
}
}
private fun downloadRegion(bb: BoundingBox, zoommin: Int, zoommax: Int) {
cacheManager.downloadAreaAsync(
activity,
bb,
zoommin,
zoommax,
object : CacheManagerCallback {
override fun onTaskComplete() {
Toast.makeText(activity, "Download complete!", Toast.LENGTH_LONG)
.show()
writer.onDetach()
2022-10-05 22:56:27 -04:00
drawOverlays()
}
override fun onTaskFailed(errors: Int) {
Toast.makeText(
activity,
"Download complete with $errors errors",
Toast.LENGTH_LONG
).show()
writer.onDetach()
}
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
}
})
}
2022-08-24 12:16:57 -04:00
private fun chooseMapStyle() {
/// Prepare dialog and its items
val builder = MaterialAlertDialogBuilder(context!!)
builder.setTitle(getString(R.string.preferences_map_style))
val mapStyles by lazy { resources.getStringArray(R.array.map_styles) }
/// Load preferences and its value
val editor: SharedPreferences.Editor = mPrefs.edit()
val mapStyleInt = mPrefs.getInt(mapStyleId, 1)
debug("mapStyleId from prefs: $mapStyleInt")
2022-08-24 12:16:57 -04:00
builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which ->
2022-08-24 12:16:57 -04:00
debug("Set mapStyleId pref to $which")
editor.putInt(mapStyleId, which)
2022-08-24 12:16:57 -04:00
editor.apply()
dialog.dismiss()
map.setTileSource(loadOnlineTileSourceBase())
2022-10-03 09:21:06 -04:00
renderDownloadButton()
2022-08-24 12:16:57 -04:00
}
val dialog = builder.create()
dialog.show()
}
2022-10-03 09:21:06 -04:00
private fun renderDownloadButton() {
if (!(map.tileProvider.tileSource as OnlineTileSourceBase).tileSourcePolicy.acceptsBulkDownload()) {
2022-10-05 22:56:27 -04:00
downloadBtn.hide()
2022-10-03 09:21:06 -04:00
} else {
2022-10-05 22:56:27 -04:00
downloadBtn.show()
2022-10-03 09:21:06 -04:00
}
}
2022-09-22 08:38:24 -04:00
private fun onWaypointChanged(wayPt: Collection<Packet>) {
/**
* Using the latest waypoint, generate GeoPoint
*/
// Find all waypoints
fun getCurrentWayPoints(): List<MarkerWithLabel> {
val wayPoint = wayPt.map { pt ->
debug("Showing on map: $pt")
lateinit var marker: MarkerWithLabel
pt.data.waypoint?.let {
val label = it.name + " " + formatAgo(it.expire)
marker = MarkerWithLabel(map, label)
marker.title = it.name
marker.snippet = it.description
2022-09-22 08:38:24 -04:00
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
marker.position = GeoPoint(it.latitudeI.toDouble(), it.longitudeI.toDouble())
}
marker
}
return wayPoint
}
wayPoints = getCurrentWayPoints()
}
private fun onNodesChanged(nodes: Collection<NodeInfo>) {
val nodesWithPosition = nodes.filter { it.validPosition != null }
/**
* Using the latest nodedb, generate GeoPoint
*/
// Find all nodes with valid locations
2022-09-08 17:46:19 -03:00
fun getCurrentNodes(): List<MarkerWithLabel> {
val mrkr = nodesWithPosition.map { node ->
val p = node.position!!
debug("Showing on map: $node")
lateinit var marker: MarkerWithLabel
node.user?.let {
val label = it.longName + " " + formatAgo(p.time)
marker = MarkerWithLabel(map, label)
2022-09-08 17:46:19 -03:00
marker.title = "${it.longName} ${node.batteryStr}"
marker.snippet = model.gpsString(p)
model.nodeDB.ourNodeInfo?.let { our ->
our.distanceStr(node)?.let { dist ->
marker.subDescription = "bearing: ${our.bearing(node)}° distance: $dist"
2022-08-30 17:25:11 -03:00
}
}
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
marker.position = GeoPoint(p.latitude, p.longitude)
marker.icon = ContextCompat.getDrawable(
2022-09-22 08:35:33 -04:00
requireActivity(), R.drawable.ic_baseline_location_on_24
)
}
marker
}
return mrkr
}
nodePositions = getCurrentNodes()
}
/**
* Create LatLong Grid line overlay
* @param enabled: turn on/off gridlines
*/
private fun createLatLongGrid(enabled: Boolean) {
val latLongGridOverlay = LatLonGridlineOverlay2()
latLongGridOverlay.isEnabled = enabled
if (latLongGridOverlay.isEnabled) {
val textPaint = Paint()
textPaint.textSize = 40f
textPaint.color = Color.GRAY
textPaint.isAntiAlias = true
textPaint.isFakeBoldText = true
textPaint.textAlign = Paint.Align.CENTER
latLongGridOverlay.textPaint = textPaint
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
latLongGridOverlay.setLineWidth(3.0f)
latLongGridOverlay.setLineColor(Color.GRAY)
map.overlayManager.add(latLongGridOverlay)
}
}
private fun drawOverlays() {
map.overlayManager.overlays().clear()
addCopyright() // Copyright is required for certain map sources
createLatLongGrid(false)
map.overlayManager.addAll(nodeLayer, nodePositions)
2022-09-22 08:38:24 -04:00
map.overlayManager.addAll(nodeLayer, wayPoints)
map.invalidate()
}
2020-04-07 12:13:50 -07:00
2022-08-24 11:08:39 -04:00
/**
* Adds copyright to map depending on what source is showing
*/
private fun addCopyright() {
if (map.tileProvider.tileSource.copyrightNotice != null) {
val copyrightNotice: String = map.tileProvider.tileSource.copyrightNotice
val copyrightOverlay = CopyrightOverlay(context)
copyrightOverlay.setCopyrightNotice(copyrightNotice)
map.overlays.add(copyrightOverlay)
}
2021-02-06 22:08:49 -08:00
}
private fun setupMapProperties() {
if (this::map.isInitialized) {
2022-08-25 10:14:19 -04:00
map.setDestroyMode(false) // keeps map instance alive when in the background.
map.isVerticalMapRepetitionEnabled = false // disables map repetition
map.setScrollableAreaLimitLatitude(
map.overlayManager.tilesOverlay.bounds.actualNorth,
map.overlayManager.tilesOverlay.bounds.actualSouth,
0
) // bounds scrollable map
2022-08-24 11:08:39 -04:00
map.isTilesScaledToDpi =
true // scales the map tiles to the display density of the screen
2022-08-24 11:08:39 -04:00
map.minZoomLevel =
defaultMinZoom // sets the minimum zoom level (the furthest out you can zoom)
2022-08-25 10:14:19 -04:00
map.setMultiTouchControls(true) // Sets gesture controls to true.
map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) // Disables default +/- button for zooming
map.addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent): Boolean {
if (binding.cacheLayout.visibility == View.VISIBLE) {
generateBoxOverlay(zoomLevelMax)
}
return true
}
override fun onZoom(event: ZoomEvent): Boolean {
return false
}
})
}
}
private fun zoomToNodes(controller: IMapController) {
val points: MutableList<GeoPoint> = mutableListOf()
val nodesWithPosition =
model.nodeDB.nodes.value?.values?.filter { it.validPosition != null }
if ((nodesWithPosition != null) && nodesWithPosition.isNotEmpty()) {
if (nodesWithPosition.size >= 2) {
// Multiple nodes, make them all fit on the map view
2022-02-05 22:03:51 -05:00
nodesWithPosition.forEach {
points.add(
GeoPoint(
2022-09-22 08:35:33 -04:00
it.position!!.latitude, it.position!!.longitude
2022-02-05 22:03:51 -05:00
)
)
}
val box = BoundingBox.fromGeoPoints(points)
val point = GeoPoint(box.centerLatitude, box.centerLongitude)
controller.animateTo(point, nodeZoomLevel, defaultZoomSpeed)
2022-02-05 20:21:42 -05:00
} else {
// Only one node, just zoom in on it
val it = nodesWithPosition[0].position!!
points.add(GeoPoint(it.latitude, it.longitude))
controller.animateTo(points[0], nodeZoomLevel, defaultZoomSpeed)
2022-02-17 10:16:58 -05:00
}
}
}
private fun loadOnlineTileSourceBase(): ITileSource {
val id = mPrefs.getInt(mapStyleId, 1)
debug("mapStyleId from prefs: $id")
return CustomTileSource.mTileSources.getOrNull(id) ?: CustomTileSource.DEFAULT_TILE_SOURCE
}
override fun onPause() {
map.onPause()
2022-09-22 08:35:33 -04:00
if (alertDialog != null && alertDialog!!.isShowing) {
alertDialog!!.dismiss()
}
if (downloadPrompt != null && downloadPrompt!!.isShowing) {
downloadPrompt!!.dismiss()
}
super.onPause()
}
override fun onResume() {
super.onResume()
2022-08-24 23:15:48 -04:00
map.onResume()
}
override fun onDestroy() {
2022-08-24 11:08:39 -04:00
super.onDestroyView()
map.onDetach()
2022-02-17 15:22:22 -05:00
}
private inner class MarkerWithLabel(mapView: MapView?, label: String) : Marker(mapView) {
val mLabel = label
2022-09-06 18:31:14 -03:00
private fun getTextBackgroundSize(text: String, x: Float, y: Float, paint: Paint): Rect {
val fontMetrics: Paint.FontMetrics = paint.fontMetrics
val halfTextLength: Float = paint.measureText(text) / 2 + 3
return Rect(
(x - halfTextLength).toInt(),
(y + fontMetrics.top).toInt(),
(x + halfTextLength).toInt(),
(y + fontMetrics.bottom).toInt()
)
}
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
draw(c, osmv)
}
fun draw(c: Canvas, osmv: MapView?) {
super.draw(c, osmv, false)
val p = mPositionPixels
val textPaint = Paint()
2022-08-30 17:25:11 -03:00
textPaint.textSize = 40f
2022-09-06 18:31:14 -03:00
textPaint.color = Color.DKGRAY
textPaint.isAntiAlias = true
2022-09-06 18:31:14 -03:00
textPaint.isFakeBoldText = true
textPaint.textAlign = Paint.Align.CENTER
2022-09-06 18:31:14 -03:00
val bgRect = getTextBackgroundSize(mLabel, (p.x - 0f), (p.y - 110f), textPaint)
val bgPaint = Paint()
bgPaint.color = Color.WHITE
c.drawRect(bgRect, bgPaint)
c.drawText(mLabel, (p.x - 0f), (p.y - 110f), textPaint)
}
}
2020-03-11 14:45:49 -07:00
}
2020-04-07 11:27:51 -07:00