package com.geeksville.mesh.ui import android.app.AlertDialog import android.graphics.Color import android.os.Bundle import android.os.Handler import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.lifecycle.Observer import androidx.recyclerview.widget.RecyclerView import com.geeksville.android.GeeksvilleApplication import com.geeksville.android.Logging import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R import com.geeksville.mesh.databinding.AdapterRegionLayoutBinding import com.geeksville.mesh.databinding.MapNotAllowedBinding import com.geeksville.mesh.databinding.MapViewBinding import com.geeksville.mesh.model.UIViewModel import com.geeksville.util.formatAgo import com.mapbox.bindgen.Value import com.mapbox.common.* import com.mapbox.geojson.* import com.mapbox.maps.* import com.mapbox.maps.dsl.cameraOptions import com.mapbox.maps.extension.style.expressions.generated.Expression import com.mapbox.maps.extension.style.layers.addLayer import com.mapbox.maps.extension.style.layers.generated.SymbolLayer import com.mapbox.maps.extension.style.layers.generated.lineLayer import com.mapbox.maps.extension.style.layers.properties.generated.* import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.GeoJsonSource import com.mapbox.maps.extension.style.sources.generated.geoJsonSource import com.mapbox.maps.plugin.animation.MapAnimationOptions import com.mapbox.maps.plugin.animation.flyTo import com.mapbox.maps.plugin.annotation.annotations import com.mapbox.maps.plugin.annotation.generated.CircleAnnotationOptions import com.mapbox.maps.plugin.annotation.generated.createCircleAnnotationManager import com.mapbox.maps.plugin.gestures.OnMapClickListener import com.mapbox.maps.plugin.gestures.OnMapLongClickListener import com.mapbox.maps.plugin.gestures.gestures import dagger.hilt.android.AndroidEntryPoint import kotlin.math.cos import kotlin.math.sin @AndroidEntryPoint class MapFragment : ScreenFragment("Map"), Logging { private val tileStore: TileStore by lazy { TileStore.create().also { // Set default access token for the created tile store instance it.setOption( TileStoreOptions.MAPBOX_ACCESS_TOKEN, TileDataDomain.MAPS, Value(getString(R.string.mapbox_access_token)) ) } } //TODO: View Offline Regions (This will allow you to select the region and the map will zoom to it) //TODO: Manage Offline Regions (Allow you to edit the name, delete, & select region) //TODO: Update download animation private val resourceOptions: ResourceOptions by lazy { ResourceOptions.Builder().applyDefaultParams(requireContext()).tileStore(tileStore).build() } private val offlineManager: OfflineManager by lazy { OfflineManager(resourceOptions) } private lateinit var handler: Handler private lateinit var binding: MapViewBinding private lateinit var mapNotAllowedBinding: MapNotAllowedBinding private lateinit var userStyleURI: String private lateinit var point: Point private val model: UIViewModel by activityViewModels() private val nodeSourceId = "node-positions" private val nodeLayerId = "node-layer" private val labelLayerId = "label-layer" private val markerImageId = "my-marker-image" private val userPointImageId = "user-image" private val boundingBoxId = "BOUNDING_BOX_ID" private val lineLayerId = "lineLayer" private var stylePackCancelable: Cancelable? = null private var tilePackCancelable: Cancelable? = null private lateinit var squareRegion: Geometry private val userTouchPositionId = "user-touch-position" private val userTouchLayerId = "user-touch-layer" private var nodePositions = GeoJsonSource(GeoJsonSource.Builder(nodeSourceId)) private val userTouchPosition = GeoJsonSource(GeoJsonSource.Builder(userTouchPositionId)) private val nodeLayer = SymbolLayer(nodeLayerId, nodeSourceId) .iconImage(markerImageId) .iconAnchor(IconAnchor.BOTTOM) .iconAllowOverlap(true) private val userTouchLayer = SymbolLayer(userTouchLayerId, userTouchPositionId) .iconImage(userPointImageId) .iconAnchor(IconAnchor.BOTTOM) .iconAllowOverlap(true) private val labelLayer = SymbolLayer(labelLayerId, nodeSourceId) .textField(Expression.get("name")) .textSize(12.0) .textColor(Color.RED) .textAnchor(TextAnchor.TOP) //.textVariableAnchor(TextAnchor.TOP) //TODO investigate need for variable anchor vs normal anchor .textJustify(TextJustify.AUTO) .textAllowOverlap(true) private fun onNodesChanged(nodes: Collection) { val nodesWithPosition = nodes.filter { it.validPosition != null } /** * Using the latest nodedb, generate geojson features */ fun getCurrentNodes(): FeatureCollection { // Find all nodes with valid locations val locations = nodesWithPosition.map { node -> val p = node.position!! debug("Showing on map: $node") val f = Feature.fromGeometry( Point.fromLngLat( p.longitude, p.latitude ) ) node.user?.let { f.addStringProperty("name", it.longName + " " + formatAgo(p.time)) } f } return FeatureCollection.fromFeatures(locations) } nodePositions.featureCollection(getCurrentNodes()) } private fun zoomToNodes(map: MapboxMap) { val points: MutableList = mutableListOf() val nodesWithPosition = model.nodeDB.nodes.value?.values?.filter { it.validPosition != null } if (nodesWithPosition != null && nodesWithPosition.isNotEmpty()) { val unit = if (nodesWithPosition.size >= 2) { // Multiple nodes, make them all fit on the map view nodesWithPosition.forEach { points.add( Point.fromLngLat( it.position!!.longitude, it.position!!.latitude ) ) } map.cameraForCoordinates(points) } else { // Only one node, just zoom in on it val it = nodesWithPosition[0].position!! points.add(Point.fromLngLat(it.longitude, it.latitude)) map.cameraForCoordinates(points) cameraOptions { this.zoom(9.0) this.center(points[0]) } } map.flyTo( unit, MapAnimationOptions.mapAnimationOptions { duration(1000) }) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { // We can't allow mapbox if user doesn't want analytics return if ((requireContext().applicationContext as GeeksvilleApplication).isAnalyticsAllowed) { // Mapbox Access token binding = MapViewBinding.inflate(inflater, container, false) binding.root } else { mapNotAllowedBinding = MapNotAllowedBinding.inflate(inflater, container, false) mapNotAllowedBinding.root } } var mapView: MapView? = null private fun showDownloadedRegions() { // Get a list of tile regions that are currently available. tileStore.getAllTileRegions { expected -> if (expected.isValue) { expected.value?.let { tileRegionList -> debug("Existing tile regions: $tileRegionList") } } expected.error?.let { tileRegionError -> debug("TileRegionError: $tileRegionError") } } // Get a list of style packs that are currently available. offlineManager.getAllStylePacks { expected -> if (expected.isValue) { expected.value?.let { stylePackList -> debug("Existing style packs: $stylePackList") } } expected.error?.let { stylePackError -> debug("StylePackError: $stylePackError") } } } private fun removeOfflineRegions() { // Remove the tile region with the tile region ID. // Note this will not remove the downloaded tile packs, instead, it will just mark the tileset // not a part of a tile region. The tiles still exists as a predictive cache in TileStore. tileStore.removeTileRegion(TILE_REGION_ID) // Set the disk quota to zero, so that tile regions are fully evicted // when removed. The TileStore is also used when `ResourceOptions.isLoadTilePacksFromNetwork` // is `true`, and also by the Navigation SDK. // This removes the tiles that do not belong to any tile regions. tileStore.setOption(TileStoreOptions.DISK_QUOTA, Value(0)) // Remove the style pack with the style url. // Note this will not remove the downloaded style pack, instead, it will just mark the resources // not a part of the existing style pack. The resources still exists as disk cache. offlineManager.removeStylePack(mapView?.getMapboxMap()?.getStyle()?.styleURI.toString()) MapboxMap.clearData(resourceOptions) { it.error?.let { error -> debug(error) } } } /** * Mapbox native code can crash painfully if you ever call a mapbox view function while the view is not actively being show */ private val isViewVisible: Boolean get() = mapView?.isVisible == true override fun onViewCreated(viewIn: View, savedInstanceState: Bundle?) { super.onViewCreated(viewIn, savedInstanceState) // binding.fabStyleToggle.setOnClickListener { // // //TODO: Setup Style menu for satellite view, street view, & outdoor view // } binding.downloadRegion.setOnClickListener { // Display menu for download region this.downloadRegionDialogFragment() } // We might not have a real mapview if running with analytics if ((requireContext().applicationContext as GeeksvilleApplication).isAnalyticsAllowed) { val vIn = viewIn.findViewById(R.id.mapView) mapView = vIn mapView?.let { v -> // Each time the pane is shown start fetching new map info (we do this here instead of // onCreate because getMapAsync can die in native code if the view goes away) val map = v.getMapboxMap() if (view != null) { // it might have gone away by now val markerIcon = ContextCompat.getDrawable( requireActivity(), R.drawable.ic_twotone_person_pin_24 )!!.toBitmap() map.loadStyleUri(Style.OUTDOORS) { if (it.isStyleLoaded) { it.addSource(nodePositions) it.addImage(markerImageId, markerIcon) it.addLayer(nodeLayer) it.addLayer(labelLayer) } } v.gestures.rotateEnabled = false v.gestures.addOnMapLongClickListener(this.longClick) v.gestures.addOnMapClickListener(this.click) // Provide initial positions model.nodeDB.nodes.value?.let { nodes -> onNodesChanged(nodes.values) } } // Any times nodes change update our map model.nodeDB.nodes.observe(viewLifecycleOwner, Observer { nodes -> if (isViewVisible) onNodesChanged(nodes.values) }) //viewAnnotationManager = v.viewAnnotationManager zoomToNodes(map) } } } private fun downloadOfflineRegion(styleURI: String = "") { val style = styleURI.ifEmpty { mapView?.getMapboxMap() ?.getStyle()?.styleURI.toString() } // By default, users may download up to 250MB of data for offline use without incurring // additional charges. This limit is subject to change during the beta. // - - - - - - - - // 1. Create style package with loadStylePack() call. // A style pack (a Style offline package) contains the loaded style and its resources: loaded // sources, fonts, sprites. Style packs are identified with their style URI. // Style packs are stored in the disk cache database, but their resources are not subject to // the data eviction algorithm and are not considered when calculating the disk cache size. stylePackCancelable = offlineManager.loadStylePack( style, // Build Style pack load options StylePackLoadOptions.Builder() .glyphsRasterizationMode(GlyphsRasterizationMode.IDEOGRAPHS_RASTERIZED_LOCALLY) .metadata(Value(STYLE_PACK_METADATA)) .build(), { progress -> // Update the download progress to UI updateStylePackDownloadProgress( progress.completedResourceCount, progress.requiredResourceCount ) }, { expected -> if (expected.isValue) { expected.value?.let { stylePack -> // Style pack download finishes successfully debug("StylePack downloaded: $stylePack") if (binding.stylePackDownloadProgress.progress == binding.stylePackDownloadProgress.max) { debug("Doing stuff") binding.stylePackDownloadProgress.visibility = View.INVISIBLE } else { debug("Waiting for tile region download to be finished.") } } } expected.error?.let { // Handle error occurred during the style pack download. debug("StylePackError: $it") } } ) // - - - - - - - - // 2. Create a tile region with tiles for the outdoors style // A Tile Region represents an identifiable geographic tile region with metadata, consisting of // a set of tiles packs that cover a given area (a polygon). Tile Regions allow caching tiles // packs in an explicit way: By creating a Tile Region, developers can ensure that all tiles in // that region will be downloaded and remain cached until explicitly deleted. // Creating a Tile Region requires supplying a description of the area geometry, the tilesets // and zoom ranges of the tiles within the region. // The tileset descriptor encapsulates the tile-specific data, such as which tilesets, zoom ranges, // pixel ratio etc. the cached tile packs should have. It is passed to the Tile Store along with // the region area geometry to load a new Tile Region. // The OfflineManager is responsible for creating tileset descriptors for the given style and zoom range. val tilesetDescriptor = offlineManager.createTilesetDescriptor( TilesetDescriptorOptions.Builder() .styleURI(style) .minZoom(0) .maxZoom(16) .build() ) // Use the the default TileStore to load this region. You can create custom TileStores are are // unique for a particular file path, i.e. there is only ever one TileStore per unique path. // Note that the TileStore path must be the same with the TileStore used when initialise the MapView. tilePackCancelable = tileStore.loadTileRegion( TILE_REGION_ID, // Make this dynamic TileRegionLoadOptions.Builder() .geometry(squareRegion) .descriptors(listOf(tilesetDescriptor)) .metadata(Value(TILE_REGION_METADATA)) .acceptExpired(true) .networkRestriction(NetworkRestriction.NONE) .build(), { progress -> updateTileRegionDownloadProgress( progress.completedResourceCount, progress.requiredResourceCount, ) } ) { expected -> if (expected.isValue) { // Tile pack download finishes successfully expected.value?.let { region -> debug("TileRegion downloaded: $region") if (binding.stylePackDownloadProgress.progress == binding.stylePackDownloadProgress.max) { debug("Finished tilepack download") binding.stylePackDownloadProgress.visibility = View.INVISIBLE } else { debug("Waiting for style pack download to be finished.") } } } expected.error?.let { // Handle error occurred during the tile region download. debug("TileRegionError: $it") } } // prepareCancelButton() } /** * OnLongClick of the map set a position marker. * If a user long-clicks again, the position of the first marker will be updated */ private val longClick = OnMapLongClickListener { val userDefinedPointImg = ContextCompat.getDrawable( requireActivity(), R.drawable.baseline_location_on_white_24dp )!! .toBitmap() point = Point.fromLngLat(it.longitude(), it.latitude()) /* Calculate region from user specified position. 5 miles NE,NW,SE,SW from user center point. 25 Sq Mile Region */ val topRight = calculateCoordinate(45.0, point.latitude(), point.longitude()) val topLeft = calculateCoordinate(135.0, point.latitude(), point.longitude()) val bottomLeft = calculateCoordinate(225.0, point.latitude(), point.longitude()) val bottomRight = calculateCoordinate(315.0, point.latitude(), point.longitude()) val pointList = listOf(topRight, topLeft, bottomLeft, bottomRight, topRight) squareRegion = LineString.fromLngLats(pointList) val geoJsonSource = geoJsonSource(boundingBoxId) { geometry(squareRegion) } val lineLayer = lineLayer(lineLayerId, boundingBoxId) { lineCap(LineCap.ROUND) lineJoin(LineJoin.MITER) lineOpacity(0.7) lineWidth(1.5) lineColor("#888") } mapView?.getMapboxMap()?.getStyle()?.let { style -> userTouchPosition.geometry(point) if (!style.styleLayerExists(userTouchLayerId)) { style.addImage(userPointImageId, userDefinedPointImg) style.addSource(userTouchPosition) style.addSource(geoJsonSource) style.addLayer(lineLayer) style.addLayer(userTouchLayer) } else { style.removeStyleLayer(lineLayerId) style.removeStyleSource(boundingBoxId) style.addSource(geoJsonSource) style.addLayer(lineLayer) } } return@OnMapLongClickListener true } /** * Find's coordinates (Lat,Lon) a specified distance from given (lat,lon) using degrees to determine direction * @param degrees Angle * @param lat latitude position * @param long longitude position * @return Point */ private fun calculateCoordinate(degrees: Double, lat: Double, long: Double): Point { val deg = Math.toRadians(degrees) val distancesInMeters = 1609.344 * 5 // 1609.344 is 1 mile in meters -> multiplier will be user specified up to a max of 10 val radiusOfEarthInMeters = 6378137 val x = long + (180 / Math.PI) * (distancesInMeters / radiusOfEarthInMeters) * cos( deg ) val y = lat + (180 / Math.PI) * (distancesInMeters / radiusOfEarthInMeters) * sin(deg) return Point.fromLngLat(x, y) } private fun updateStylePackDownloadProgress( progress: Long, max: Long, ) { binding.stylePackDownloadProgress.visibility = View.VISIBLE binding.stylePackDownloadProgress.max = max.toInt() binding.stylePackDownloadProgress.progress = progress.toInt() } private fun updateTileRegionDownloadProgress( progress: Long, max: Long, ) { binding.stylePackDownloadProgress.max = max.toInt() binding.stylePackDownloadProgress.progress = progress.toInt() } private val click = OnMapClickListener { //binding.fabStyleToggle.isVisible && if (binding.downloadRegion.isVisible) { //binding.fabStyleToggle.visibility = View.INVISIBLE binding.downloadRegion.visibility = View.INVISIBLE } else { //binding.fabStyleToggle.visibility = View.VISIBLE binding.downloadRegion.visibility = View.VISIBLE } return@OnMapClickListener true } companion object { private const val ZOOM = 9.0 private const val TILE_REGION_ID = "myTileRegion" private const val STYLE_PACK_METADATA = "my-outdoor-style-pack" private const val TILE_REGION_METADATA = "my-outdoors-tile-region" } private fun downloadRegionDialogFragment() { val mapDownloadView = layoutInflater.inflate(R.layout.dialog_map_download, null) val uri = mapDownloadView.findViewById(R.id.uri) val downloadRegionDialogFragment = AlertDialog.Builder(context) downloadRegionDialogFragment.setView(mapDownloadView) .setTitle("Download Region") .setMultiChoiceItems( R.array.MapMenuCheckbox, null, ) { _, _, isChecked -> if (isChecked) { if (!uri.isVisible) { uri.visibility = View.VISIBLE } } else { if (uri.isVisible) { uri.visibility = View.GONE } } } .setPositiveButton( "Save" ) { _, _ -> if (uri.isVisible) { // Save URI userStyleURI = uri.text.toString() uri.setText("") // clear text } if ((this::point.isInitialized) && this::userStyleURI.isInitialized) { saveDialog(userStyleURI) } else { // Tell user to select region val text = "You must select a region on the map, long press to set the region you want to download" val duration = Toast.LENGTH_LONG val toast = Toast.makeText(requireContext(), text, duration) toast.show() } } .setNeutralButton("View Regions") { dialog, _ -> val regions = layoutInflater.inflate(R.layout.adapter_region_layout, null) val regionFragment = AlertDialog.Builder(context) regionFragment.setView(regions) regionFragment.create() regionFragment.show() // Open up Downloaded Region managers // mapView?.getMapboxMap().also { // it?.flyTo( // CameraOptions.Builder() // .zoom(ZOOM) // .center(point) // .build(), MapAnimationOptions.mapAnimationOptions { duration(1000) }) // it?.loadStyleUri(mapView?.getMapboxMap()?.getStyle()?.styleURI.toString()) // } } .setNegativeButton( R.string.cancel ) { dialog, _ -> mapView?.getMapboxMap()?.getStyle { style -> style.removeStyleLayer(lineLayerId) style.removeStyleSource(boundingBoxId) style.removeStyleLayer(userTouchLayerId) style.removeStyleSource(userTouchPositionId) style.removeStyleImage(userPointImageId) } removeOfflineRegions() //TODO: Add to offline manager window dialog.cancel() } downloadRegionDialogFragment.create() downloadRegionDialogFragment.show() } class ViewHolder(itemView: AdapterRegionLayoutBinding) : RecyclerView.ViewHolder(itemView.root) { val offlineRegion: ImageView = itemView.offlineRegion val regionName: TextView = itemView.regionName } private fun saveDialog(styleURI: String = "") { val nameRegionDialog = AlertDialog.Builder(context) nameRegionDialog.setView(R.layout.dialog_map_name) nameRegionDialog.setTitle("Name") nameRegionDialog.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } nameRegionDialog.setPositiveButton("Save") { _, _ -> downloadOfflineRegion(styleURI) } nameRegionDialog.create() nameRegionDialog.show() } private val offlineRegionAdapter = object : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val inflater = LayoutInflater.from(requireContext()) val regionViewBinding = AdapterRegionLayoutBinding.inflate(inflater, parent, false) return ViewHolder(regionViewBinding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { var regionList = mutableListOf() val region = regionList[position] val name = "" tileStore.getAllTileRegions { expected -> if (expected.isValue) { expected.value?.let { tileRegionList -> regionList = tileRegionList } } } } override fun getItemCount(): Int { var count = 0 tileStore.getAllTileRegions { expected -> if (expected.isValue) { expected.value?.let { tileRegionList -> count = tileRegionList.size } } expected.error?.let { tileRegionError -> debug("TileRegionError: $tileRegionError") } } return count } } }