diff --git a/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt b/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt index 359f0c7..57816a5 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt +++ b/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt @@ -1,488 +1,495 @@ package it.reyboz.bustorino.backend.mato import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.lifecycle.LifecycleOwner import com.hivemq.client.mqtt.MqttClient import com.hivemq.client.mqtt.lifecycle.MqttClientAutoReconnect import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientBuilder import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish import it.reyboz.bustorino.BuildConfig import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import org.json.JSONArray import org.json.JSONException import java.lang.ref.WeakReference import java.util.* import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock typealias PositionsMap = HashMap > class MQTTMatoClient(){ private var isStarted = false //private var subscribedToAll = false private var client: Mqtt3AsyncClient? = null //private var clientID = "" private val respondersMap = HashMap>>() private val currentPositions = PositionsMap() private val positionsLock = ReentrantLock() private lateinit var lifecycle: LifecycleOwner //TODO: remove class reference to context (always require context in all methods) private var context: Context?= null //private var connectionTrials = 0 //private var notification: Notification? = null private fun connect(context: Context, onConnect: OnConnect){ //val clientID = "mqtt-explorer-${getRandomString(8)}"//"mqttjs_${getRandomString(8)}" if (this.context ==null) this.context = context.applicationContext //notification = Notifications.makeMQTTServiceNotification(context) val mclient = makeClientBuilder().buildAsync() Log.d(DEBUG_TAG, "Connecting to MQTT server") //actually connect val mess = mclient.connect().whenComplete { subAck, throwable -> if(throwable!=null){ Log.w(DEBUG_TAG,"Failed to connect to MQTT server:") Log.w(DEBUG_TAG,throwable.toString()) } else{ isStarted = true Log.d(DEBUG_TAG, "Connected to MQTT server") onConnect.onConnectSuccess() } } client = mclient /* if (mess.returnCode != Mqtt3ConnAckReturnCode.SUCCESS){ Log.w(DEBUG_TAG,"Failed to connect to MQTT client") return false } else{ client = blockingClient.toAsync() isStarted = true return true } */ } /*override fun connectComplete(reconnect: Boolean, serverURI: String?) { Log.d(DEBUG_TAG, "Connected to server, reconnect: $reconnect") Log.d(DEBUG_TAG, "Have listeners: $respondersMap") } */ private fun subscribeMQTTTopic(topic: String){ if(context==null){ Log.e(DEBUG_TAG, "Trying to connect but context is null") return } client!!.subscribeWith() .topicFilter(topic).callback {publish -> messageArrived(publish.topic.toString(), publish) }.send().whenComplete { subAck, throwable-> if(throwable != null){ Log.e(DEBUG_TAG, "Error while subscribing to topic $topic", throwable) } else{ //add the responder to the list Log.d(DEBUG_TAG, "Subscribed to topic $topic, responders ready: ${respondersMap[topic]}") } } } private fun subscribeTopicAddResponder(topic: String, responderWR: WeakReference, lineId: String){ if (!respondersMap.contains(lineId)){ respondersMap[lineId] = ArrayList() } respondersMap[lineId]!!.add(responderWR) subscribeMQTTTopic(topic) } fun startAndSubscribe(lineId: String, responder: MQTTMatoListener, context: Context): Boolean{ //start the client, and then subscribe to the topic val topic = mapTopic(lineId) val vrResp = WeakReference(responder) this.context = context.applicationContext synchronized(this) { if(!isStarted || (client == null)){ connect(context.applicationContext) { //when connection is done, run this subscribeTopicAddResponder(topic, vrResp, lineId) } //wait for connection } else { subscribeTopicAddResponder(topic, vrResp, lineId) } //recheck if it is started } return true } fun stopMatoRequests(responder: MQTTMatoListener){ var removed = false for ((lineTopic,responderList)in respondersMap.entries){ var done = false for (el in responderList){ if (el.get()==null){ responderList.remove(el) } else if(el.get() == responder){ responderList.remove(el) done = true } if (done) break } if(done) Log.d(DEBUG_TAG, "Removed one listener for topic $lineTopic, listeners: $responderList") //if (done) break if (responderList.isEmpty()){ //actually unsubscribe try { val topic = mapTopic(lineTopic) client?.run{ unsubscribeWith().addTopicFilter(topic).send().whenComplete { subAck, throwable -> if (throwable!=null){ //error occurred Log.e(DEBUG_TAG, "Error while unsubscribing to topic $topic",throwable) } } } } catch (e: Exception){ Log.e(DEBUG_TAG, "Tried unsubscribing but there was an error in the client library:\n$e") } } removed = done || removed } // check responders map, remove lines that have no responders for(line in respondersMap.keys){ if(respondersMap[line]?.isEmpty() == true){ respondersMap.remove(line) } } Log.d(DEBUG_TAG, "Removed: $removed, respondersMap: $respondersMap") } /** * Cancel the notification */ fun removeNotification(context: Context){ val notifManager = context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notifManager.cancel(MQTT_NOTIFICATION_ID) } private fun sendUpdateToResponders(responders: ArrayList>): Int{ //var sent = false var count = 0 for (wrD in responders) { if (wrD.get() == null) { Log.d(DEBUG_TAG, "Removing weak reference") responders.remove(wrD) } else { val resp = wrD.get()!! resp.onStatusUpdate(LivePositionsServiceStatus.OK) positionsLock.withLock { resp.onUpdateReceived(currentPositions) } //sent = true count++ } } return count } private fun sendStatusToResponders(status: LivePositionsServiceStatus){ val responders = respondersMap for (els in respondersMap.values) for (wrD in els) { if (wrD.get() == null) { Log.d(DEBUG_TAG, "Removing weak reference") els.remove(wrD) } else { wrD.get()!!.onStatusUpdate(status) //sent = true } } } /*override fun connectionLost(cause: Throwable?) { var doReconnect = false for ((line,elms) in respondersMap.entries){ if(!elms.isEmpty()){ doReconnect = true break } } if (!doReconnect){ Log.d(DEBUG_TAG, "Disconnected, but no responders to give the positions, avoid reconnecting") //finish here return } Log.w(DEBUG_TAG, "Lost connection in MQTT Mato Client") synchronized(this){ // isStarted = false //var i = 0 // while(i < 20 && !isStarted) { connect(context!!, object: IMqttActionListener{ override fun onSuccess(asyncActionToken: IMqttToken?) { //relisten to messages for ((line,elms) in respondersMap.entries){ val topic = mapTopic(line) if(elms.isEmpty()) respondersMap.remove(line) else { client!!.subscribe(topic, QoS.AtMostOnce.value, null, null) Log.d(DEBUG_TAG, "Resubscribed with topic $topic") } } Log.d(DEBUG_TAG, "Reconnected to MQTT Mato Client") } override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) { Log.w(DEBUG_TAG, "Failed to reconnect to MQTT server") } }) } } */ fun messageArrived(topic: String?, message: Mqtt3Publish) { if (topic==null) return //Log.d(DEBUG_TAG,"Arrived message on topic $topic, ${String(message.payload)}") parseMessageAndAddToList(topic, message) //GlobalScope.launch { } } private fun parseMessageAndAddToList(topic: String, message: Mqtt3Publish){ //val mqttTopic = message.topic //Log.d(DEBUG_TAG, "Topic of message received: $mqttTopic") val vals = topic.split("/") val lineId = vals[1] val vehicleId = vals[2] val timestamp = makeUnixTimestamp() val messString = String(message.payloadAsBytes) if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Received message on topic: $topic") try { val jsonList = JSONArray(messString) if(jsonList.get(4)==null){ Log.d(DEBUG_TAG, "We have null tripId: line $lineId veh $vehicleId: $jsonList") sendStatusToResponders(LivePositionsServiceStatus.NO_POSITIONS) return } val posUpdate = LivePositionUpdate( jsonList.getString(4)+"U", null, null, lineId+"U", vehicleId, jsonList.getDouble(0), //latitude jsonList.getDouble(1), //longitude if(jsonList.get(2).equals(null)) null else jsonList.getInt(2).toFloat(), //"heading" (same as bearing?) timestamp, if(jsonList.get(6).equals(null)) null else jsonList.getInt(6).toString() //nextStop ) //add update //var valid = false positionsLock.withLock { if (!currentPositions.contains(lineId)) currentPositions[lineId] = HashMap() currentPositions[lineId]!!.let { it[vehicleId] = posUpdate //valid = true } } //sending //Log.d(DEBUG_TAG, "Parsed update on topic $topic, line $lineId, responders $respondersMap") var cc = 0 if (LINES_ALL in respondersMap.keys) { val count = sendUpdateToResponders(respondersMap[LINES_ALL]!!) cc +=count } if(lineId in respondersMap.keys){ cc += sendUpdateToResponders(respondersMap[lineId]!!) } //Log.d(DEBUG_TAG, "Sent to $cc responders, have $respondersMap") if(cc==0){ Log.w(DEBUG_TAG, "We have received an update but apparently there is no one to send it") var emptyResp = true for(en in respondersMap.values){ if(!en.isEmpty()){ emptyResp=false break } } //try unsubscribing to all if(emptyResp) { Log.d(DEBUG_TAG, "Unsubscribe all") //client!!.unsubscribe(LINES_ALL) } } //Log.d(DEBUG_TAG, "We have update on line $lineId, vehicle $vehicleId") } catch (e: JSONException){ Log.w(DEBUG_TAG,"Cannot decipher message on topic $topic, line $lineId, veh $vehicleId (bad JSON)") sendStatusToResponders(LivePositionsServiceStatus.ERROR_PARSING_RESPONSE) } catch (e: Exception){ Log.e(DEBUG_TAG, "Exception occurred", e) sendStatusToResponders(LivePositionsServiceStatus.ERROR_PARSING_RESPONSE) } } /** * Remove positions older than `timeMins` minutes */ fun clearOldPositions(timeMins: Int){ val currentTimeStamp = makeUnixTimestamp() var c = 0 positionsLock.withLock{ - for ((k, manyp) in currentPositions.entries) { - for ((t, p) in manyp.entries) { + for ((k, posByVeh) in currentPositions) { + /*for (t in posByVeh.keys.toList()) { // iterate over snapshot to avoid modification error + val p = posByVeh[t] ?: continue if (currentTimeStamp - p.timestamp > timeMins * 60) { - manyp.remove(t) + posByVeh.remove(t) c += 1 } } + */ + c+=posByVeh.entries.removeIf{ el -> + currentTimeStamp - el.value.timestamp > timeMins * 60 + }.let { + if(it) 1 else 0 + } } } Log.d(DEBUG_TAG, "Removed $c positions older than $timeMins minutes") } /*/** * Stop the service forever. Client has not to be used again!! */ fun closeClientForever(){ client.disconnect() client.close() }*/ fun disconnect(){ client?.disconnect() } companion object{ const val SERVER_ADDR="mapi.5t.torino.it" const val SERVER_PATH="/scre" const val LINES_ALL="ALL" private const val DEBUG_TAG="BusTO-MatoMQTT" //this has to match the value in MQTT library (MQTTAndroidClient) const val MQTT_NOTIFICATION_ID: Int = 77 @JvmStatic fun mapTopic(lineId: String): String{ return if(lineId== LINES_ALL || lineId == "#") "#" else{ "/${lineId}/#" } } fun getRandomString(length: Int) : String { val allowedChars = ('a'..'f') + ('0'..'9') return (1..length) .map { allowedChars.random() } .joinToString("") } interface MQTTMatoListener{ //positionsMap is a dict with line -> vehicle -> Update fun onUpdateReceived(posUpdates: PositionsMap) fun onStatusUpdate(status: LivePositionsServiceStatus) } fun getHTTPHeaders(): HashMap { val headers = HashMap() headers["Origin"] = "https://mato.muoversiatorino.it" headers["Host"] = "mapi.5t.torino.it" return headers } private fun makeClientBuilder() : Mqtt3ClientBuilder{ val clientID = "mqtt-explorer-${getRandomString(8)}" val r = MqttClient.builder() .useMqttVersion3() .identifier(clientID) .serverHost(SERVER_ADDR) .serverPort(443) .sslWithDefaultConfig() .automaticReconnect(MqttClientAutoReconnect.builder() .initialDelay(500, TimeUnit.MILLISECONDS) .maxDelay(60, TimeUnit.SECONDS).build()) .webSocketConfig() .httpHeaders(getHTTPHeaders()) .serverPath("scre") .applyWebSocketConfig() //.webSocketWithDefaultConfig() return r } private fun makeUnixTimestamp(): Long{ val timestamp = (System.currentTimeMillis() / 1000 ) return timestamp } } private fun interface OnConnect{ fun onConnectSuccess() } } /*data class MQTTPositionUpdate( val lineId: String, val vehicleId: String, val latitude: Double, val longitude: Double, val heading: Int?, val speed: Int?, val tripId: String?, val direct: Int?, val nextStop: Int?, //val full: Int? )*/ \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt index 4270bbf..c8a2c91 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,197 +1,209 @@ package it.reyboz.bustorino.fragments import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.google.gson.JsonObject import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.data.PreferencesHolder import org.maplibre.android.MapLibre import org.maplibre.android.camera.CameraPosition import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView import org.maplibre.android.maps.OnMapReadyCallback import org.maplibre.android.maps.Style import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.Point abstract class GeneralMapLibreFragment: ScreenBaseFragment(), OnMapReadyCallback { protected var map: MapLibreMap? = null protected var shownStopInBottomSheet : Stop? = null protected var savedMapStateOnPause : Bundle? = null // Declare a variable for MapView protected lateinit var mapView: MapView protected lateinit var mapStyle: Style protected lateinit var stopsSource: GeoJsonSource protected lateinit var busesSource: GeoJsonSource protected lateinit var selectedStopSource: GeoJsonSource protected lateinit var sharedPreferences: SharedPreferences private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener(){ pref, key -> /*when(key){ SettingsFragment.LIBREMAP_STYLE_PREF_KEY -> reloadMap() } */ if(key == SettingsFragment.LIBREMAP_STYLE_PREF_KEY){ Log.d(DEBUG_TAG,"ASKING RELOAD OF MAP") reloadMap() } } private var lastMapStyle ="" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) //init map MapLibre.getInstance(requireContext()) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onCreateView lastMapStyle: $lastMapStyle") return super.onCreateView(inflater, container, savedInstanceState) } override fun onResume() { + mapView.onResume() super.onResume() val newMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onResume newMapStyle: $newMapStyle, lastMapStyle: $lastMapStyle") if(newMapStyle!=lastMapStyle){ reloadMap() } } @Deprecated("Deprecated in Java") override fun onLowMemory() { - super.onLowMemory() mapView.onLowMemory() + super.onLowMemory() + } + + override fun onStart() { + super.onStart() + mapView.onStart() + } + + override fun onDestroy() { + mapView.onDestroy() + Log.d(DEBUG_TAG, "Destroyed mapView Fragment!!") + super.onDestroy() } protected fun reloadMap(){ /*map?.let { Log.d("GeneralMapFragment", "RELOADING MAP") //save map state savedMapStateOnPause = saveMapStateInBundle() onMapDestroy() //Destroy and recreate MAP mapView.onDestroy() mapView.onCreate(null) mapView.getMapAsync(this) } */ //TODO figure out how to switch map safely } abstract fun openStopInBottomSheet(stop: Stop) //For extra stuff to do when the map is destroyed abstract fun onMapDestroy() protected fun restoreMapStateFromBundle(bundle: Bundle): Boolean{ val nullDouble = -10_000.0 var boundsRestored =false val latCenter = bundle.getDouble("center_map_lat", nullDouble) val lonCenter = bundle.getDouble("center_map_lon",nullDouble) val zoom = bundle.getDouble("map_zoom", nullDouble) val bearing = bundle.getDouble("map_bearing", nullDouble) val tilt = bundle.getDouble("map_tilt", nullDouble) if(lonCenter!=nullDouble &&latCenter!=nullDouble) map?.let { val center = LatLng(latCenter, lonCenter) val newPos = CameraPosition.Builder().target(center) if(zoom>0) newPos.zoom(zoom) if(bearing!=nullDouble) newPos.bearing(bearing) if(tilt != nullDouble) newPos.tilt(tilt) it.cameraPosition=newPos.build() Log.d(DEBUG_TAG, "Restored map state from Bundle, center: $center, zoom: $zoom, bearing $bearing, tilt $tilt") boundsRestored =true } else{ Log.d(DEBUG_TAG, "Not restoring map state, center: $latCenter,$lonCenter; zoom: $zoom, bearing: $bearing, tilt $tilt") } val mStop = bundle.getBundle("shown_stop")?.let { Stop.fromBundle(it) } mStop?.let { openStopInBottomSheet(it) } return boundsRestored } protected fun saveMapStateBeforePause(bundle: Bundle){ map?.let { val newBbox = it.projection.visibleRegion.latLngBounds val cp = it.cameraPosition bundle.putDouble("center_map_lat", newBbox.center.latitude) bundle.putDouble("center_map_lon", newBbox.center.longitude) it.cameraPosition.zoom.let { z-> bundle.putDouble("map_zoom",z) } bundle.putDouble("map_bearing",cp.bearing) bundle.putDouble("map_tilt", cp.tilt) val locationComponent = it.locationComponent bundle.putBoolean(KEY_LOCATION_ENABLED,locationComponent.isLocationComponentEnabled) bundle.putParcelable("last_location", locationComponent.lastKnownLocation) } shownStopInBottomSheet?.let { bundle.putBundle("shown_stop", it.toBundle()) } } protected fun saveMapStateInBundle(): Bundle { val b = Bundle() saveMapStateBeforePause(b) return b } protected fun stopToGeoJsonFeature(s: Stop): Feature{ return Feature.fromGeometry( Point.fromLngLat(s.longitude!!, s.latitude!!), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) } protected fun isPointInsideVisibleRegion(p: LatLng, other: Boolean): Boolean{ val bounds = map?.projection?.visibleRegion?.latLngBounds var inside = other bounds?.let { inside = it.contains(p) } return inside } protected fun isPointInsideVisibleRegion(lat: Double, lon: Double, other: Boolean): Boolean{ val p = LatLng(lat, lon) return isPointInsideVisibleRegion(p, other) } companion object{ private const val DEBUG_TAG="GeneralMapLibreFragment" const val BUSES_SOURCE_ID = "buses-source" const val BUSES_LAYER_ID = "buses-layer" const val SEL_STOP_SOURCE="selected-stop-source" const val SEL_STOP_LAYER = "selected-stop-layer" const val KEY_LOCATION_ENABLED="location_enabled" } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt index 2b792f5..cfe9627 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,1626 +1,1615 @@ /* BusTO - Fragments components Copyright (C) 2023 Fabio Mazza 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 . */ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.content.res.ColorStateList import android.location.Location import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.LinearInterpolator import android.widget.* import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.NameCapitalize import it.reyboz.bustorino.adapters.StopAdapterListener import it.reyboz.bustorino.adapters.StopRecyclerAdapter import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.LivePositionTripPattern import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.gtfs.PolylineParser import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.* import it.reyboz.bustorino.middleware.LocationUtils import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import kotlinx.coroutines.Runnable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.location.LocationComponent import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol import org.maplibre.android.plugins.annotation.SymbolManager import org.maplibre.android.plugins.annotation.SymbolOptions import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER import org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.LineString import org.maplibre.geojson.Point import java.util.concurrent.atomic.AtomicBoolean class LinesDetailFragment() : GeneralMapLibreFragment() { private var lineID = "" private lateinit var patternsSpinner: Spinner private var patternsAdapter: ArrayAdapter? = null //Bottom sheet behavior private lateinit var bottomSheetBehavior: BottomSheetBehavior private var bottomLayout: RelativeLayout? = null private lateinit var stopTitleTextView: TextView private lateinit var stopNumberTextView: TextView private lateinit var linesPassingTextView: TextView private lateinit var arrivalsCard: CardView private lateinit var directionsCard: CardView private lateinit var bottomrightImage: ImageView //private var isBottomSheetShowing = false private var shouldMapLocationBeReactivated = true private var toRunWhenMapReady : Runnable? = null private var mapInitialized = AtomicBoolean(false) //private var patternsSpinnerState: Parcelable? = null private lateinit var currentPatterns: List //private lateinit var map: MapView private var patternShown: MatoPatternWithStops? = null private val viewModel: LinesViewModel by viewModels() private val mapViewModel: MapViewModel by viewModels() private var firstInit = true private var pausedFragment = false private lateinit var switchButton: ImageButton private var favoritesButton: ImageButton? = null private var locationIcon: ImageButton? = null private var isLineInFavorite = false private var appContext: Context? = null private var isLocationPermissionOK = false private val lineSharedPrefMonitor = SharedPreferences.OnSharedPreferenceChangeListener { pref, keychanged -> if(keychanged!=PreferencesHolder.PREF_FAVORITE_LINES || lineID.isEmpty()) return@OnSharedPreferenceChangeListener val newFavorites = pref.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) newFavorites?.let {favorites-> isLineInFavorite = favorites.contains(lineID) //if the button has been intialized, change the icon accordingly favoritesButton?.let { button-> //avoid crashes if fragment not attached if(context==null) return@let if(isLineInFavorite) { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) appContext?.let { Toast.makeText(it,R.string.favorites_line_add,Toast.LENGTH_SHORT).show()} } else { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_outline, null)) appContext?.let {Toast.makeText(it,R.string.favorites_line_remove,Toast.LENGTH_SHORT).show()} } } } } private lateinit var stopsRecyclerView: RecyclerView private lateinit var descripTextView: TextView private var stopIDFromToShow: String? = null //adapter for recyclerView private val stopAdapterListener= object : StopAdapterListener { override fun onTappedStop(stop: Stop?) { if(viewModel.shouldShowMessage) { Toast.makeText(context, R.string.long_press_stop_4_options, Toast.LENGTH_SHORT).show() viewModel.shouldShowMessage=false } stop?.let { fragmentListener.requestArrivalsForStopID(it.ID) } if(stop == null){ Log.e(DEBUG_TAG,"Passed wrong stop") } if(fragmentListener == null){ Log.e(DEBUG_TAG, "Fragment listener is null") } } override fun onLongPressOnStop(stop: Stop?): Boolean { TODO("Not yet implemented") } } private val patternsSorter = Comparator{ p1: MatoPatternWithStops, p2: MatoPatternWithStops -> if(p1.pattern.directionId != p2.pattern.directionId) return@Comparator p1.pattern.directionId - p2.pattern.directionId else return@Comparator -1*(p1.stopsIndices.size - p2.stopsIndices.size) } //map data //style and sources are in GeneralMapLibreFragment private lateinit var locationComponent: LocationComponent private lateinit var polylineSource: GeoJsonSource private lateinit var polyArrowSource: GeoJsonSource private lateinit var selectedBusSource: GeoJsonSource private var savedCameraPosition: CameraPosition? = null private var vehShowing = "" private var stopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastUpdateTime:Long = -2 //BUS POSITIONS private val updatesByVehDict = HashMap(5) private val animatorsByVeh = HashMap() private var lastLocation : Location? = null private var enablingPositionFromClick = false private var polyline: LineString? = null private val showUserPositionRequestLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapUserLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //private var stopPosList = ArrayList() //fragment actions private lateinit var fragmentListener: CommonFragmentListener private var showOnTopOfLine = false private var recyclerInitDone = false private var useMQTTPositions = true //position of live markers private val tripMarkersAnimators = HashMap() private val liveBusViewModel: LivePositionsViewModel by activityViewModels() //extra items to use the LibreMap private lateinit var symbolManager : SymbolManager private var stopActiveSymbol: Symbol? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = requireArguments() lineID = args.getString(LINEID_KEY,"") stopIDFromToShow = args.getString(STOPID_FROM_KEY) } @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { //reset statuses //isBottomSheetShowing = false //stopsLayerStarted = false lastStopsSizeShown = 0 mapInitialized.set(false) val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) //lineID = requireArguments().getString(LINEID_KEY, "") arguments?.let { lineID = it.getString(LINEID_KEY, "") } switchButton = rootView.findViewById(R.id.switchImageButton) locationIcon = rootView.findViewById(R.id.locationEnableIcon) favoritesButton = rootView.findViewById(R.id.favoritesButton) stopsRecyclerView = rootView.findViewById(R.id.patternStopsRecyclerView) descripTextView = rootView.findViewById(R.id.lineDescripTextView) descripTextView.visibility = View.INVISIBLE //map stuff mapView = rootView.findViewById(R.id.lineMap) mapView.getMapAsync(this) //init bottom sheet val bottomSheet = rootView.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = bottomSheet.findViewById(R.id.stopTitleTextView) stopNumberTextView = bottomSheet.findViewById(R.id.stopNumberTextView) linesPassingTextView = bottomSheet.findViewById(R.id.linesPassingTextView) arrivalsCard = bottomSheet.findViewById(R.id.arrivalsCardButton) directionsCard = bottomSheet.findViewById(R.id.directionsCardButton) bottomrightImage = bottomSheet.findViewById(R.id.rightmostImageView) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopBottomSheet() } val titleTextView = rootView.findViewById(R.id.titleTextView) titleTextView.text = getString(R.string.line)+" "+FiveTNormalizer.fixShortNameForDisplay( GtfsUtils.getLineNameFromGtfsID(lineID), true) favoritesButton?.isClickable = true favoritesButton?.setOnClickListener { if(lineID.isNotEmpty()) PreferencesHolder.addOrRemoveLineToFavorites(requireContext(),lineID,!isLineInFavorite) } val preferences = PreferencesHolder.getMainSharedPreferences(requireContext()) val favorites = preferences.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) if(favorites!=null && favorites.contains(lineID)){ favoritesButton?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) isLineInFavorite = true } appContext = requireContext().applicationContext preferences.registerOnSharedPreferenceChangeListener(lineSharedPrefMonitor) patternsSpinner = rootView.findViewById(R.id.patternsSpinner) patternsAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, ArrayList()) patternsSpinner.adapter = patternsAdapter initializeRecyclerView() switchButton.setOnClickListener{ if(mapView.visibility == View.VISIBLE){ hideMapAndShowStopList() } else{ hideStopListAndShowMap() } } locationIcon?.let {view -> if(!LocationUtils.isLocationEnabled(requireContext()) || !Permissions.anyLocationPermissionsGranted(requireContext())) setLocationIconEnabled(false) //set click Listener view.setOnClickListener(this::onPositionIconButtonClick) } //set //INITIALIZE VIEW MODELS viewModel.setRouteIDQuery(lineID) liveBusViewModel.setGtfsLineToFilterPos(lineID, null) val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner){ patterns -> savePatternsToShow(patterns) } /* */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> if(mapView.visibility ==View.VISIBLE) patternShown?.let{ // We have the pattern and the stops here, time to display them displayPatternWithStopsOnMap(it,stops, true) } ?:{ Log.w(DEBUG_TAG, "The viewingPattern is null!") } else{ if(stopsRecyclerView.visibility==View.VISIBLE) showStopsInRecyclerView(stops) } } viewModel.gtfsRoute.observe(viewLifecycleOwner){route-> if(route == null){ //need to close the fragment activity?.supportFragmentManager?.popBackStack() return@observe } descripTextView.text = route.longName descripTextView.visibility = View.VISIBLE } /* */ Log.d(DEBUG_TAG,"Data ${viewModel.stopsForPatternLiveData.value}") //listeners patternsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { val currentShownPattern = patternShown?.pattern val patternWithStops = currentPatterns[position] Log.d(DEBUG_TAG, "request stops for pattern ${patternWithStops.pattern.code}") setPatternAndReqStops(patternWithStops) if(mapView.visibility == View.VISIBLE) { //Clear buses if we are changing direction currentShownPattern?.let { patt -> if(patt.directionId != patternWithStops.pattern.directionId){ stopAnimations() updatesByVehDict.clear() updatePositionsIcons(true) liveBusViewModel.retriggerPositionUpdate() } } } liveBusViewModel.setGtfsLineToFilterPos(lineID, patternWithStops.pattern) } override fun onNothingSelected(p0: AdapterView<*>?) { } } Log.d(DEBUG_TAG, "Views created!") return rootView } // ------------- UI switch stuff --------- private fun hideMapAndShowStopList(){ mapView.visibility = View.GONE stopsRecyclerView.visibility = View.VISIBLE locationIcon?.visibility = View.GONE viewModel.setMapShowing(false) if(useMQTTPositions) liveBusViewModel.stopMatoUpdates() //map.overlayManager.remove(busPositionsOverlay) switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_map_white_30)) hideStopBottomSheet() if(locationComponent.isLocationComponentEnabled){ locationComponent.isLocationComponentEnabled = false shouldMapLocationBeReactivated = true } else shouldMapLocationBeReactivated = false } private fun hideStopListAndShowMap(){ stopsRecyclerView.visibility = View.GONE mapView.visibility = View.VISIBLE locationIcon?.visibility = View.VISIBLE viewModel.setMapShowing(true) //map.overlayManager.add(busPositionsOverlay) //map. if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_list_30)) if(shouldMapLocationBeReactivated && Permissions.bothLocationPermissionsGranted(requireContext())){ locationComponent.isLocationComponentEnabled = true } } private fun setLocationIconEnabled(setTrue: Boolean){ if(setTrue) locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapUserLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions") locationComponent.isLocationComponentEnabled = true //locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() //TODO: Cancel the request for the enablement of the position if needed } } } /** * Switch position icon from activ */ private fun onPositionIconButtonClick(view: View){ if(locationComponent.isLocationComponentEnabled) setMapUserLocationEnabled(false, false, true) else{ setMapUserLocationEnabled(true, false, true) } } // ------------- Map Code ------------------------- /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady val context = requireContext() val mjson = MapLibreStyles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") activity?.run { val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(style) // Start observing data initMapUserLocation(style, mapReady, requireContext()) //if(!stopsLayerStarted) initStopsPolyLineLayers(style, FeatureCollection.fromFeatures(ArrayList()), null, null) setupBusLayer(style) symbolManager = SymbolManager(mapView,mapReady,style) symbolManager.iconAllowOverlap = true symbolManager.textAllowOverlap = false symbolManager.addClickListener{ _ -> if (stopActiveSymbol!=null){ hideStopBottomSheet() return@addClickListener true } else return@addClickListener false } mapViewModel.stopShowing?.let { openStopInBottomSheet(it) } mapViewModel.stopShowing = null toRunWhenMapReady?.run() toRunWhenMapReady = null mapInitialized.set(true) if(patternShown!=null){ viewModel.stopsForPatternLiveData.value?.let { Log.d(DEBUG_TAG, "Show stops from the cache") displayPatternWithStopsOnMap(patternShown!!, it, true) } } } mapReady.addOnMapClickListener { point -> val screenPoint = mapReady.projection.toScreenLocation(point) val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) if (features.isNotEmpty()) { val feature = features[0] val id = feature.getStringProperty("id") val name = feature.getStringProperty("name") //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = viewModel.getStopByID(id) stop?.let { if (isBottomSheetShowing() || vehShowing.isNotEmpty()){ hideStopBottomSheet() } openStopInBottomSheet(it) //move camera if(it.latitude!=null && it.longitude!=null) mapReady.animateCamera(CameraUpdateFactory.newLatLng(LatLng(it.latitude!!,it.longitude!!)),750) } return@addOnMapClickListener true } else if (busNearby.isNotEmpty()){ val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") if(isBottomSheetShowing()) hideStopBottomSheet() //if(context!=null){ // Toast.makeText(context, "Veh $vehid on route ${route.slice(0..route.length-2)}", Toast.LENGTH_SHORT).show() //} showVehicleTripInBottomSheet(vehid) updatesByVehDict[vehid]?.let { //if (it.posUpdate.latitude != null && it.longitude != null) mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(it.posUpdate.latitude, it.posUpdate.longitude)), 750 ) } return@addOnMapClickListener true } false } // we start requesting the bus positions now observeBusPositionUpdates() } /*savedMapStateOnPause?.let{ restoreMapStateFromBundle(it) pendingLocationActivation = false Log.d(DEBUG_TAG, "Restored map state from the saved bundle") } */ val zoom = 12.0 val latlngTarget = LatLng(MapLibreFragment.DEFAULT_CENTER_LAT, MapLibreFragment.DEFAULT_CENTER_LON) mapReady.cameraPosition = savedCameraPosition ?:CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() savedCameraPosition = null if(shouldMapLocationBeReactivated) setMapUserLocationEnabled(true, false, false) } private fun observeBusPositionUpdates(){ //live bus positions liveBusViewModel.filteredLocationUpdates.observe(viewLifecycleOwner){ pair -> //Log.d(DEBUG_TAG, "Received ${updates.size} updates for the positions") val updates = pair.first val vehiclesNotOnCorrectDir = pair.second if(mapView.visibility == View.GONE || patternShown ==null){ //DO NOTHING Log.w(DEBUG_TAG, "not doing anything because map is not visible") return@observe } //remove vehicles not on this direction removeVehiclesData(vehiclesNotOnCorrectDir) updateBusPositionsInMap(updates) //if not using MQTT positions if(!useMQTTPositions){ liveBusViewModel.requestDelayedGTFSUpdates(2000) } } //download missing tripIDs liveBusViewModel.tripsGtfsIDsToQuery.observe(viewLifecycleOwner){ //gtfsPosViewModel.downloadTripsFromMato(dat); MatoTripsDownloadWorker.requestMatoTripsDownload( it, requireContext().applicationContext, "BusTO-MatoTripDownload" ) } } private fun isBottomSheetShowing(): Boolean{ return bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED } /** * Initialize the map location, but do not enable the component */ @SuppressLint("MissingPermission") private fun initMapUserLocation(style: Style, map: MapLibreMap, context: Context){ locationComponent = map.locationComponent val locationComponentOptions = LocationComponentOptions.builder(context) .pulseEnabled(false) .build() val locationComponentActivationOptions = MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) locationComponent.activateLocationComponent(locationComponentActivationOptions) locationComponent.isLocationComponentEnabled = false lastLocation?.let { if (it.accuracy < 200) locationComponent.forceLocationUpdate(it) } } /** * Update the bottom sheet with the stop information */ override fun openStopInBottomSheet(stop: Stop){ bottomLayout?.let { //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" val stopName = stop.stopUserName ?: stop.stopDefaultName stopTitleTextView.text = stopName//stop.stopDefaultName stopNumberTextView.text = getString(R.string.stop_fill,stop.ID) stopTitleTextView.visibility = View.VISIBLE val string_show = if (stop.numRoutesStopping==0) "" else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show //SET ON CLICK LISTENER arrivalsCard.setOnClickListener{ fragmentListener?.requestArrivalsForStopID(stop.ID) } arrivalsCard.visibility = View.VISIBLE directionsCard.visibility = View.VISIBLE directionsCard.setOnClickListener { ViewUtils.openStopInOutsideApp(stop, context) } context?.let { val colorIcon = ViewUtils.getColorFromTheme(it, android.R.attr.colorAccent)//ResourcesCompat.getColor(resources,R.attr.colorAccent,activity?.theme) ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorIcon)) } bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.navigation_right, activity?.theme)) } //add stop marker if (stop.latitude!=null && stop.longitude!=null) { stopActiveSymbol = symbolManager.create( SymbolOptions() .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) .withIconImage(STOP_ACTIVE_IMG) .withIconAnchor(ICON_ANCHOR_CENTER) ) } Log.d(DEBUG_TAG, "Shown stop $stop in bottom sheet") shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED //isBottomSheetShowing = true } // Hide the bottom sheet and remove extra symbol private fun hideStopBottomSheet(){ if (stopActiveSymbol!=null){ symbolManager.delete(stopActiveSymbol) stopActiveSymbol = null } bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN //isBottomSheetShowing = false //reset states shownStopInBottomSheet = null if (vehShowing!=""){ //we are hiding a vehicle vehShowing = "" updatePositionsIcons(true) } } private fun showVehicleTripInBottomSheet(veh: String){ val data = updatesByVehDict[veh] if(data==null) { Log.w(DEBUG_TAG,"Asked to show vehicle $veh, but it's not present in the updates") return } bottomLayout?.let { val lineName = FiveTNormalizer.fixShortNameForDisplay( GtfsUtils.getLineNameFromGtfsID(data.posUpdate.routeID), true) val pat = data.pattern if (pat!=null){ //WE HAVE THE DIRECTIONS DATA stopTitleTextView.text = pat.headsign stopTitleTextView.visibility = View.VISIBLE Log.d(DEBUG_TAG, "Showing headsign ${pat.headsign} for vehicle $veh") stopNumberTextView.text = requireContext().getString(R.string.line_fill_towards, lineName) bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_magnifying_glass, activity?.theme)) directionsCard.setOnClickListener { data.pattern?.let { if(patternShown?.pattern?.code == it.code){ context?.let { c->Toast.makeText(c, R.string.showing_same_direction, Toast.LENGTH_SHORT).show() } }else showPatternWithCode(it.code) } //TODO // ?: { // context?.let { ctx -> Toast.makeText(ctx,"") } //} } //set color val colorBlue = ResourcesCompat.getColor(resources,R.color.blue_500,activity?.theme) ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorBlue)) directionsCard.visibility = View.VISIBLE } else { //stopTitleTextView.text = "NN" stopTitleTextView.visibility = View.GONE stopNumberTextView.text = requireContext().getString(R.string.line_fill, lineName) directionsCard.visibility = View.GONE } linesPassingTextView.text = requireContext().getString(R.string.vehicle_fill, data.posUpdate.vehicle) } arrivalsCard.visibility=View.GONE vehShowing = veh bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED //call update position to color the bus special updatePositionsIcons(true) //isBottomSheetShowing = true Log.d(DEBUG_TAG, "Shown vehicle $veh in bottom layout") } // ------- MAP LAYERS INITIALIZE ---- /** * Initialize the map layers for the stops */ private fun initStopsPolyLineLayers(style: Style, stopFeatures:FeatureCollection, lineFeature: Feature?, arrowFeatures: FeatureCollection?){ Log.d(DEBUG_TAG, "INIT STOPS CALLED") stopsSource = GeoJsonSource(STOPS_SOURCE_ID) style.addSource(stopsSource) //val context = requireContext() val stopIcon = ResourcesCompat.getDrawable(resources,R.drawable.ball, activity?.theme)!! val imgStop = ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!! val polyIconArrow = ResourcesCompat.getDrawable(resources, R.drawable.arrow_up_box_fill, activity?.theme)!! //set the image tint //DrawableCompat.setTint(imgBus,ContextCompat.getColor(context,R.color.line_drawn_poly)) // add icon style.addImage(STOP_IMAGE_ID,stopIcon) style.addImage(POLY_ARROW, polyIconArrow) style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) // Stops layer val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) stopsLayer.withProperties( PropertyFactory.iconImage(STOP_IMAGE_ID), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true) ) polylineSource = GeoJsonSource(POLYLINE_SOURCE) //lineFeature?.let { GeoJsonSource(POLYLINE_SOURCE, it) } ?: GeoJsonSource(POLYLINE_SOURCE) style.addSource(polylineSource) val color=ContextCompat.getColor(requireContext(),R.color.line_drawn_poly) //paint.style = Paint.Style.FILL_AND_STROKE //paint.strokeJoin = Paint.Join.ROUND //paint.strokeCap = Paint.Cap.ROUND val lineLayer = LineLayer(POLYLINE_LAYER, POLYLINE_SOURCE).withProperties( PropertyFactory.lineColor(color), PropertyFactory.lineWidth(5.0f), //originally 13f PropertyFactory.lineOpacity(1.0f), PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND), PropertyFactory.lineCap(Property.LINE_CAP_ROUND) ) polyArrowSource = GeoJsonSource(POLY_ARROWS_SOURCE, arrowFeatures) style.addSource(polyArrowSource) val arrowsLayer = SymbolLayer(POLY_ARROWS_LAYER, POLY_ARROWS_SOURCE).withProperties( PropertyFactory.iconImage(POLY_ARROW), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) val layers = style.layers val lastLayers = layers.filter { l-> l.id.contains("city") } //Log.d(DEBUG_TAG,"Layers:\n ${style.layers.map { l -> l.id }}") Log.d(DEBUG_TAG, "City layers: ${lastLayers.map { l-> l.id }}") if(lastLayers.isNotEmpty()) style.addLayerAbove(lineLayer,lastLayers[0].id) else style.addLayerBelow(lineLayer,"label_country_1") style.addLayerAbove(stopsLayer, POLYLINE_LAYER) style.addLayerAbove(arrowsLayer, POLYLINE_LAYER) stopsLayerStarted = true } /** * Setup the Map Layers */ private fun setupBusLayer(style: Style) { // Buses source busesSource = GeoJsonSource(BUSES_SOURCE_ID) style.addSource(busesSource) style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) selectedBusSource = GeoJsonSource("sel_bus_source") style.addSource(selectedBusSource) style.addImage("sel_bus_symbol", ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon_sel, activity?.theme)!!) // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage("bus_symbol"), //PropertyFactory.iconSize(1.2f), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) } style.addLayerAbove(busesLayer, STOPS_LAYER_ID) val selectedBusLayer = SymbolLayer("sel_bus_layer", "sel_bus_source").withProperties( PropertyFactory.iconImage("sel_bus_symbol"), //PropertyFactory.iconSize(1.2f), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) style.addLayerAbove(selectedBusLayer, BUSES_LAYER_ID) } override fun onAttach(context: Context) { super.onAttach(context) if(context is CommonFragmentListener){ fragmentListener = context } else throw RuntimeException("$context must implement CommonFragmentListener") } private fun stopAnimations(){ for(anim in animatorsByVeh.values){ anim.cancel() } } /** * Save the loaded pattern data, without the stops! */ private fun savePatternsToShow(patterns: List){ currentPatterns = patterns.sortedWith(patternsSorter) patternsAdapter?.let { it.clear() it.addAll(currentPatterns.map { p->"${p.pattern.directionId} - ${p.pattern.headsign}" }) it.notifyDataSetChanged() } // if we are loading from a stop, find it val patternToShow = stopIDFromToShow?.let { sID -> val stopGtfsID = "gtt:$sID" var p: MatoPatternWithStops? = null var pLength = 0 for(patt in currentPatterns){ for(pstop in patt.stopsIndices){ if(pstop.stopGtfsId == stopGtfsID){ //found if (patt.stopsIndices.size>pLength){ p = patt pLength = patt.stopsIndices.size } //break here, we have determined this pattern has the stop we're looking for break } } } p } if(stopIDFromToShow!=null){ if(patternToShow==null) Log.w(DEBUG_TAG, "We had to show the pattern from stop $stopIDFromToShow, but we didn't find it") else Log.d(DEBUG_TAG, "Requesting to show pattern from stop $stopIDFromToShow, found pattern ${patternToShow.pattern.code}") } //unset the stopID to show if(patternToShow!=null) { //showPattern(patternToShow) patternShown = patternToShow stopIDFromToShow = null } patternShown?.let { showPattern(it) } } /** * Called when the position of the spinner is updated */ private fun setPatternAndReqStops(patternWithStops: MatoPatternWithStops){ Log.d(DEBUG_TAG, "Requesting stops for pattern ${patternWithStops.pattern.code}") viewModel.selectedPatternLiveData.value = patternWithStops viewModel.currentPatternStops.value = patternWithStops.stopsIndices.sortedBy { i-> i.order } patternShown = patternWithStops viewModel.requestStopsForPatternWithStops(patternWithStops) } private fun showPattern(patternWs: MatoPatternWithStops){ //Log.d(DEBUG_TAG, "Finding pattern to show: ${patternWs.pattern.code}") var pos = -2 val code = patternWs.pattern.code.trim() for (k in currentPatterns.indices) { if (currentPatterns[k].pattern.code.trim() == code) { pos = k break } } Log.d(DEBUG_TAG, "Requesting stops fro pattern $code in position: $pos") if (pos !=-2) patternsSpinner.setSelection(pos) else Log.e(DEBUG_TAG, "Pattern with code $code not found!!") //request pattern stops from DB //setPatternAndReqStops(patternWs) } private fun zoomToCurrentPattern(){ if(polyline==null) return val NULL_VALUE = -4000.0 var maxLat = NULL_VALUE var minLat = NULL_VALUE var minLong = NULL_VALUE var maxLong = NULL_VALUE polyline?.let { for(p in it.coordinates()){ val lat = p.latitude() val lon = p.longitude() // get max latitude if(maxLat == NULL_VALUE) maxLat =lat else if (maxLat < lat) maxLat = lat // find min latitude if (minLat ==NULL_VALUE) minLat = lat else if (minLat > lat) minLat = lat if(maxLong == NULL_VALUE || maxLong < lon ) maxLong = lon if (minLong == NULL_VALUE || minLong > lon) minLong = lon } val padding = 50 // Pixel di padding intorno ai limiti Log.d(DEBUG_TAG, "Setting limits of bounding box of line: $minLat -> $maxLat, $minLong -> $maxLong") val bbox = LatLngBounds.from(maxLat,maxLong, minLat, minLong) //map.zoomToBoundingBox(BoundingBox(maxLat+del, maxLong+del, minLat-del, minLong-del), false) map?.animateCamera(CameraUpdateFactory.newLatLngBounds(bbox, padding)) } } private fun displayPatternWithStopsOnMap(patternWs: MatoPatternWithStops, stopsToSort: List, zoomToPattern: Boolean){ if(!mapInitialized.get()){ //set the runnable and do nothing else Log.d(DEBUG_TAG, "Delaying pattern display to when map is Ready: ${patternWs.pattern.code}") toRunWhenMapReady = Runnable { displayPatternWithStopsOnMap(patternWs, stopsToSort, zoomToPattern) } return } Log.d(DEBUG_TAG, "Got the stops: ${stopsToSort.map { s->s.gtfsID }}}") patternShown = patternWs //Problem: stops are not sorted val stopOrderD = patternWs.stopsIndices.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stopsToSort.sortedBy { s-> stopOrderD[s.gtfsID] } val pattern = patternWs.pattern val pointsList = PolylineParser.decodePolyline(pattern.patternGeometryPoly, pattern.patternGeometryLength) val pointsToShow = pointsList.map { Point.fromLngLat(it.longitude, it.latitude) } Log.d(DEBUG_TAG, "The polyline has ${pointsToShow.size} points to display") polyline = LineString.fromLngLats(pointsToShow) val lineFeature = Feature.fromGeometry(polyline) //Log.d(DEBUG_TAG, "Polyline in JSON is: ${lineFeature.toJson()}") // --- STOPS--- val features = ArrayList() for (s in stopsSorted){ if (s.latitude!=null && s.longitude!=null) { val loc = if (showOnTopOfLine) findOptimalPosition(s, pointsList) else LatLng(s.latitude!!, s.longitude!!) features.add( Feature.fromGeometry( Point.fromLngLat(loc.longitude, loc.latitude), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) ) } } // -- ARROWS -- //val splitPolyline = MapLibreUtils.splitPolyWhenDistanceTooBig(pointsList, 200.0) val arrowFeatures = ArrayList() val pointsIndexToShowIcon = MapLibreUtils.findPointsToPutDirectionMarkers(pointsList, stopsSorted, 750.0) for (idx in pointsIndexToShowIcon){ val pnow = pointsList[idx] val otherp = if(idx>1) pointsList[idx-1] else pointsList[idx+1] val bearing = if (idx>1) MapLibreUtils.getBearing(pointsList[idx-1], pnow) else MapLibreUtils.getBearing(pnow, pointsList[idx+1]) arrowFeatures.add(Feature.fromGeometry( Point.fromLngLat((pnow.longitude+otherp.longitude)/2, (pnow.latitude+otherp.latitude)/2 ), //average JsonObject().apply { addProperty("bearing", bearing) } )) } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") // if the layer is already started, substitute the stops inside, otherwise start it if (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) polylineSource.setGeoJson(lineFeature) polyArrowSource.setGeoJson(FeatureCollection.fromFeatures(arrowFeatures)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initStopsPolyLineLayers(mapStyle, FeatureCollection.fromFeatures(features),lineFeature, FeatureCollection.fromFeatures(arrowFeatures)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } ?:{ Log.e(DEBUG_TAG, "Stops layer is not started!!") } /* OLD CODE for(s in stops){ val gp = val marker = MarkerUtils.makeMarker( gp, s.ID, s.stopDefaultName, s.routesThatStopHereToString(), map,stopTouchResponder, stopIcon, R.layout.linedetail_stop_infowindow, R.color.line_drawn_poly ) marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) stopsOverlay.add(marker) } */ //POINTS LIST IS NOT IN ORDER ANY MORE //if(!map.overlayManager.contains(stopsOverlay)){ // map.overlayManager.add(stopsOverlay) //} if(zoomToPattern) zoomToCurrentPattern() //map.invalidate() } private fun initializeRecyclerView(){ val llManager = LinearLayoutManager(context) llManager.orientation = LinearLayoutManager.VERTICAL stopsRecyclerView.layoutManager = llManager } private fun showStopsInRecyclerView(stops: List){ Log.d(DEBUG_TAG, "Setting stops from: "+viewModel.currentPatternStops.value) val orderBy = viewModel.currentPatternStops.value!!.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stops.sortedBy { s -> orderBy[s.gtfsID] } val numStops = stopsSorted.size Log.d(DEBUG_TAG, "RecyclerView adapter is: ${stopsRecyclerView.adapter}") val setNewAdapter = true if(setNewAdapter){ stopsRecyclerView.adapter = StopRecyclerAdapter( stopsSorted, stopAdapterListener, StopRecyclerAdapter.Use.LINES, NameCapitalize.FIRST ) } } /** * This method fixes the display of the pattern, to be used when clicking on a bus */ private fun showPatternWithCode(patternId: String){ //var index = 0 Log.d(DEBUG_TAG, "Showing pattern with code $patternId ") for (i in currentPatterns.indices){ val pattStop = currentPatterns[i] if(pattStop.pattern.code == patternId){ Log.d(DEBUG_TAG, "Pattern found in position $i") //setPatternAndReqStops(pattStop) patternsSpinner.setSelection(i) break } } } private fun removeVehiclesData(vehs: List){ for(v in vehs){ if (updatesByVehDict.contains(v)) { updatesByVehDict.remove(v) if (animatorsByVeh.contains(v)){ animatorsByVeh[v]?.cancel() animatorsByVeh.remove(v) } } if (vehShowing==v){ hideStopBottomSheet() } } } /** * Update function for the bus positions * Takes the processed updates and saves them accordingly * Copied from MapLibreFragment, removing the labels */ private fun updateBusPositionsInMap(incomingData: HashMap>){ val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) val vehsOld = HashSet(updatesByVehDict.keys) Log.d(DEBUG_TAG, "In fragment, have ${incomingData.size} updates to show") var countUpds = 0 var createdVehs = 0 //val symbolsToUpdate = ArrayList() for (upsWithTrp in incomingData.values){ val newPos = upsWithTrp.first val patternStops = upsWithTrp.second val vehID = newPos.vehicle var animate = false if (vehsOld.contains(vehID)){ //changing the location of an existing bus //update position only if the starting or the stopping position of the animation are in the view val oldPos = updatesByVehDict[vehID]?.posUpdate val oldPattern = updatesByVehDict[vehID]?.pattern var avoidShowingUpdateBecauseIsImpossible = false oldPos?.let{ if(it.routeID!=newPos.routeID) { val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(newPos.latitude, newPos.longitude)) val speed = dist*3.6 / (newPos.timestamp - it.timestamp) //this should be in km/h Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${newPos.routeID}, distance: $dist, speed: $speed") if (speed > 120 || speed < 0){ avoidShowingUpdateBecauseIsImpossible = true } } } if (avoidShowingUpdateBecauseIsImpossible){ // DO NOT SHOW THIS SHIT Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") continue } val samePosition = oldPos?.let { (it.latitude==newPos.latitude)&&(it.longitude == newPos.longitude) }?:false val setPattern = (oldPattern==null) && (patternStops!=null) if(newPos.bearing == null && oldPos?.bearing != null){ //copy old bearing newPos.bearing = oldPos.bearing } if((!samePosition)|| setPattern) { val newOrOldPosInBounds = isPointInsideVisibleRegion( newPos.latitude, newPos.longitude, true ) || (oldPos?.let { isPointInsideVisibleRegion(it.latitude,it.longitude,true) } ?: false) //val skip = true if (newOrOldPosInBounds) { // update the pattern data, the position will be updated with the animation patternStops?.let { updatesByVehDict[vehID]!!.pattern = it.pattern} //this moves both the icon and the label animateNewPositionMove(newPos) } else { //update updatesByVehDict[vehID] = LivePositionTripPattern(newPos,patternStops?.pattern) /*busLabelSymbolsByVeh[vehID]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) }*/ //if(vehShowing==vehID) // map?.animateCamera(CameraUpdateFactory.newLatLng(LatLng(pos.latitude, pos.longitude)),500) //TODO: Follow the vehicle } } countUpds++ } else{ //not inside // update it simply updatesByVehDict[vehID] = LivePositionTripPattern(newPos, patternStops?.pattern) //createLabelForVehicle(pos) //if(vehShowing==vehID) // map?.animateCamera(CameraUpdateFactory.newLatLng(LatLng(pos.latitude, pos.longitude)),500) createdVehs +=1 } if (vehID == vehShowing){ //update the data showVehicleTripInBottomSheet(vehID) } } //symbolManager.update(symbolsToUpdate) //remove old positions Log.d(DEBUG_TAG, "Updated $countUpds vehicles, created $createdVehs vehicles") vehsOld.removeAll(vehsNew) //now vehsOld contains the vehicles id for those that have NOT been updated val currentTimeStamp = System.currentTimeMillis() /1000 for(vehID in vehsOld){ //remove after 2 minutes of inactivity if (updatesByVehDict[vehID]!!.posUpdate.timestamp - currentTimeStamp > 2*60){ //remove the bus updatesByVehDict.remove(vehID) if(vehID in animatorsByVeh){ animatorsByVeh[vehID]?.cancel() animatorsByVeh.remove(vehID) } //removeVehicleLabel(vehID) } } //update UI updatePositionsIcons(false) } /** * This is the tricky part, animating the transitions * Basically, we need to set the new positions with the data and redraw them all */ private fun animateNewPositionMove(positionUpdate: LivePositionUpdate){ if (positionUpdate.vehicle !in updatesByVehDict.keys) return val vehID = positionUpdate.vehicle val currentUpdate = updatesByVehDict[positionUpdate.vehicle] currentUpdate?.let { it -> //cancel current animation on vehicle animatorsByVeh[vehID]?.cancel() val posUp = it.posUpdate val currentPos = LatLng(posUp.latitude, posUp.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) val valueAnimator = ValueAnimator.ofObject(MapLibreUtils.LatLngEvaluator(), currentPos, newPos) valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { private var latLng: LatLng? = null override fun onAnimationUpdate(animation: ValueAnimator) { latLng = animation.animatedValue as LatLng //update position on animation val update = updatesByVehDict[positionUpdate.vehicle] if(update!=null){ latLng?.let { ll -> update.posUpdate.latitude = ll.latitude update.posUpdate.longitude = ll.longitude updatePositionsIcons(false) } } else{ //The update is null Log.w(DEBUG_TAG, "The bus position to animate has been removed, but the animator is still running!") } } }) /*valueAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator) { super.onAnimationStart(animation) //val update = positionsByVehDict[positionUpdate.vehicle]!! //remove the label at the start of the animation //removeVehicleLabel(vehID) val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 0.0f symbolsToUpdate.add(sym) } } override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) /*val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 1.0f sym.latLng = newPos //LatLng(newPos) symbolsToUpdate.add(sym) } */ } }) */ animatorsByVeh[vehID]?.cancel() //set the new position as the current one but with the old lat and lng positionUpdate.latitude = posUp.latitude positionUpdate.longitude = posUp.longitude //this might be null if the updates dict does not contain the vehID updatesByVehDict[vehID]!!.posUpdate = positionUpdate valueAnimator.duration = 300 valueAnimator.interpolator = LinearInterpolator() valueAnimator.start() animatorsByVeh[vehID] = valueAnimator } ?: { Log.e(DEBUG_TAG, "Have to run animation for veh ${positionUpdate.vehicle} but not in the dict, adding") //updatesByVehDict[positionUpdate.vehicle] = positionUpdate } } //TODO: MERGE THIS CODE WITH MapLibreFragment ONE /** * Update the bus positions displayed on the map, from the existing data */ private fun updatePositionsIcons(forced: Boolean){ //avoid frequent updates val currentTime = System.currentTimeMillis() if(!forced && currentTime - lastUpdateTime < 60){ //DO NOT UPDATE THE MAP viewLifecycleOwner.lifecycleScope.launch { delay(200) updatePositionsIcons(forced) } return } val busFeatures = ArrayList() val selectedBusFeatures = ArrayList() for (dat in updatesByVehDict.values){ //if (s.latitude!=null && s.longitude!=null) val pos = dat.posUpdate val point = Point.fromLngLat(pos.longitude, pos.latitude) val newFeature = Feature.fromGeometry( point, JsonObject().apply { addProperty("veh", pos.vehicle) addProperty("trip", pos.tripID) addProperty("bearing", pos.bearing ?:0.0f) addProperty("line", pos.routeID) } ) if (vehShowing == dat.posUpdate.vehicle) selectedBusFeatures.add(newFeature) else busFeatures.add(newFeature) /*busLabelSymbolsByVeh[pos.vehicle]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } busesSource.setGeoJson(FeatureCollection.fromFeatures(busFeatures)) selectedBusSource.setGeoJson(FeatureCollection.fromFeatures(selectedBusFeatures)) //update labels, clear cache to be used //symbolManager.update(symbolsToUpdate) //symbolsToUpdate.clear() lastUpdateTime = System.currentTimeMillis() } override fun onResume() { super.onResume() Log.d(DEBUG_TAG, "Resetting paused from onResume") - mapView.onResume() pausedFragment = false val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") //separate paths if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() if(mapViewModel.currentLat.value!=MapViewModel.INVALID) { Log.d(DEBUG_TAG, "mapViewModel posi: ${mapViewModel.currentLat.value}, ${mapViewModel.currentLong.value}"+ " zoom ${mapViewModel.currentZoom.value}") //THIS WAS A FIX FOR THE OLD OSMDROID MAP /*val controller = map.controller viewLifecycleOwner.lifecycleScope.launch { delay(100) Log.d(DEBUG_TAG, "zooming back to point") controller.animateTo(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!), mapViewModel.currentZoom.value!!,null,null) //controller.setCenter(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!)) //controller.setZoom(mapViewModel.currentZoom.value!!) } */ } //initialize GUI here fragmentListener.readyGUIfor(FragmentKind.LINES) } override fun onPause() { super.onPause() mapView.onPause() if(useMQTTPositions) liveBusViewModel.stopMatoUpdates() pausedFragment = true //save map val camera = map?.cameraPosition camera?.let {cam-> mapViewModel.currentLat.value = cam.target?.latitude ?: -400.0 mapViewModel.currentLong.value = cam.target?.longitude ?: -400.0 mapViewModel.currentZoom.value = cam.zoom } } - override fun onStart() { - super.onStart() - mapView.onStart() - } - - override fun onDestroy() { - super.onDestroy() - mapView.onDestroy() - } - override fun onStop() { super.onStop() mapView.onStop() shownStopInBottomSheet?.let { mapViewModel.stopShowing = it } shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled } override fun onDestroyView() { map?.run { Log.d(DEBUG_TAG, "Saving camera position") savedCameraPosition = cameraPosition } super.onDestroyView() Log.d(DEBUG_TAG, "Destroying the views") /*mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle?.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) */ //stopsLayerStarted = false } override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return null } companion object { private const val LINEID_KEY="lineID" private const val STOPID_FROM_KEY="stopID" private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" private const val STOP_ACTIVE_IMG = "stop_active_img" private const val STOP_IMAGE_ID = "stop-img" private const val POLYLINE_LAYER = "polyline-layer" private const val POLYLINE_SOURCE = "polyline-source" private const val POLY_ARROWS_LAYER = "arrows-layer" private const val POLY_ARROWS_SOURCE = "arrows-source" private const val POLY_ARROW ="poly-arrow-img" private const val DEBUG_TAG="BusTO-LineDetalFragment" fun makeArgs(lineID: String, stopIDFrom: String?): Bundle{ val b = Bundle() b.putString(LINEID_KEY, lineID) b.putString(STOPID_FROM_KEY, stopIDFrom) return b } fun newInstance(lineID: String?, stopIDFrom: String?) = LinesDetailFragment().apply { lineID?.let { arguments = makeArgs(it, stopIDFrom) } } @JvmStatic private fun findOptimalPosition(stop: Stop, pointsList: MutableList): LatLng{ if(stop.latitude==null || stop.longitude ==null|| pointsList.isEmpty()) throw IllegalArgumentException() val sLat = stop.latitude!! val sLong = stop.longitude!! if(pointsList.size < 2) return pointsList[0] pointsList.sortBy { utils.measuredistanceBetween(sLat, sLong, it.latitude, it.longitude) } val p1 = pointsList[0] val p2 = pointsList[1] if (p1.longitude == p2.longitude){ //Log.e(DEBUG_TAG, "Same longitude") return LatLng(sLat, p1.longitude) } else if (p1.latitude == p2.latitude){ //Log.d(DEBUG_TAG, "Same latitude") return LatLng(p2.latitude,sLong) } val m = (p1.latitude - p2.latitude) / (p1.longitude - p2.longitude) val minv = (p1.longitude-p2.longitude)/(p1.latitude - p2.latitude) val cR = p1.latitude - p1.longitude * m val longNew = (minv * sLong + sLat -cR ) / (m+minv) val latNew = (m*longNew + cR) //Log.d(DEBUG_TAG,"Stop ${stop.ID} old pos: ($sLat, $sLong), new pos ($latNew,$longNew)") return LatLng(latNew,longNew) } private const val DEFAULT_CENTER_LAT = 45.12 private const val DEFAULT_CENTER_LON = 7.6858 } enum class BottomShowing{ STOP, VEHICLE } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt index a74ca6c..d502eec 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,1243 +1,1235 @@ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.graphics.Color import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.LinearInterpolator import android.widget.ImageButton import android.widget.RelativeLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.mato.MQTTMatoClient import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.MapLibreUtils import it.reyboz.bustorino.map.MapLibreStyles import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import it.reyboz.bustorino.viewmodels.StopsMapViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.location.LocationComponent import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.location.modes.CameraMode import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.layers.Property.* import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.Point // TODO: Rename parameter arguments, choose names that match // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER private const val STOP_TO_SHOW = "stoptoshow" /** * A simple [Fragment] subclass. * Use the [MapLibreFragment.newInstance] factory method to * create an instance of this fragment. */ class MapLibreFragment : GeneralMapLibreFragment() { protected var fragmentListener: CommonFragmentListener? = null private lateinit var locationComponent: LocationComponent private var lastLocation: Location? = null private val stopsViewModel: StopsMapViewModel by viewModels() private var stopsShowing = ArrayList(0) private var isBottomSheetShowing = false //private lateinit var symbolManager: SymbolManager // Sources for stops and buses are in GeneralMapLibreFragment private var isUserMovingCamera = false private var stopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastBBox = LatLngBounds.from(2.0, 2.0, 1.0,1.0) private var mapInitCompleted =false private var stopsRedrawnTimes = 0 //bottom Sheet behavior private lateinit var bottomSheetBehavior: BottomSheetBehavior private var bottomLayout: RelativeLayout? = null private lateinit var stopTitleTextView: TextView private lateinit var stopNumberTextView: TextView private lateinit var linesPassingTextView: TextView private lateinit var arrivalsCard: CardView private lateinit var directionsCard: CardView //private var stopActiveSymbol: Symbol? = null // Location stuff private lateinit var locationManager: LocationManager private lateinit var showUserPositionButton: ImageButton private lateinit var centerUserButton: ImageButton private lateinit var followUserButton: ImageButton private var followingUserLocation = false private var pendingLocationActivation = false private var ignoreCameraMovementForFollowing = true private var enablingPositionFromClick = false private val positionRequestLauncher = registerForActivityResult, Map>( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") }else if(!pendingLocationActivation){ /// SHOULD DO NOTHING HERE Log.d(DEBUG_TAG, "Requested location but now there is no pendingLocationActivation") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay Log.d(DEBUG_TAG, "HAVE THE PERMISSIONS") if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult val locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager var lastLoc = stopsViewModel.lastUserLocation @SuppressLint("MissingPermission") if(lastLoc==null) lastLoc = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) else Log.d(DEBUG_TAG, "Got last location from cache") if (lastLoc != null) { if(LatLng(lastLoc.latitude, lastLoc.longitude).distanceTo(DEFAULT_LATLNG) <= MAX_DIST_KM*1000){ Log.d(DEBUG_TAG, "Showing the user position") setMapLocationEnabled(true, true, false) } else{ setMapLocationEnabled(false, false,false) context?.let{Toast.makeText(it,R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show()} } } else requestInitialUserLocation() } else{ Toast.makeText(requireContext(),R.string.location_disabled, Toast.LENGTH_SHORT).show() Log.w(DEBUG_TAG, "No location permission") } }) private val showUserPositionRequestLauncher = registerForActivityResult, Map>( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //BUS POSITIONS private var usingMQTTPositions = true // THIS IS INSIDE VIEW MODEL NOW private val livePositionsViewModel : LivePositionsViewModel by activityViewModels() private lateinit var busPositionsIconButton: ImageButton private val positionsByVehDict = HashMap(5) private val animatorsByVeh = HashMap() private var lastUpdateTime : Long = -1 //private var busLabelSymbolsByVeh = HashMap() private val symbolsToUpdate = ArrayList() private var initialStopToShow : Stop? = null private var initialStopShown = false private var waitingDelayedBusUpdate = false //shown stuff //private var savedStateOnStop : Bundle? = null private val showBusLayer = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { initialStopToShow = Stop.fromBundle(arguments) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // Inflate the layout for this fragment val rootView = inflater.inflate(R.layout.fragment_map_libre, container, false) //reset the counter lastStopsSizeShown = 0 stopsRedrawnTimes = 0 stopsLayerStarted = false symbolsToUpdate.clear() // Init layout view // Init the MapView mapView = rootView.findViewById(R.id.libreMapView) val restoreBundle = stopsViewModel.savedState if(restoreBundle!=null){ mapView.onCreate(restoreBundle) } else mapView.onCreate(savedInstanceState) mapView.getMapAsync(this) //{ //map -> //map.setStyle("https://demotiles.maplibre.org/style.json") } //init bottom sheet val bottomSheet = rootView.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = bottomSheet.findViewById(R.id.stopTitleTextView) stopNumberTextView = bottomSheet.findViewById(R.id.stopNumberTextView) linesPassingTextView = bottomSheet.findViewById(R.id.linesPassingTextView) arrivalsCard = bottomSheet.findViewById(R.id.arrivalsCardButton) directionsCard = bottomSheet.findViewById(R.id.directionsCardButton) showUserPositionButton = rootView.findViewById(R.id.locationEnableIcon) showUserPositionButton.setOnClickListener(this::switchUserLocationStatus) followUserButton = rootView.findViewById(R.id.followUserImageButton) centerUserButton = rootView.findViewById(R.id.centerMapImageButton) busPositionsIconButton = rootView.findViewById(R.id.busPositionsImageButton) busPositionsIconButton.setOnClickListener { LivePositionsDialogFragment().show(parentFragmentManager, "LivePositionsDialog") } bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN arrivalsCard.setOnClickListener { if(context!=null){ Toast.makeText(context,"ARRIVALS", Toast.LENGTH_SHORT).show() } } centerUserButton.setOnClickListener { if(context!=null && locationComponent.isLocationComponentEnabled) { val location = locationComponent.lastKnownLocation location?.let { mapView.getMapAsync { map -> map.animateCamera(CameraUpdateFactory.newCameraPosition( CameraPosition.Builder().target(LatLng(location.latitude, location.longitude)).build()), 500) } } } } followUserButton.setOnClickListener { // onClick user following button if(context!=null && locationComponent.isLocationComponentEnabled){ if(followingUserLocation) locationComponent.cameraMode = CameraMode.NONE else locationComponent.cameraMode = CameraMode.TRACKING // CameraMode.TRACKING makes the camera move and jump to the location setFollowingUser(!followingUserLocation) } } locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager if (Permissions.bothLocationPermissionsGranted(requireContext())) { requestInitialUserLocation() } else{ if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT) .show() } } // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopBottomSheet() } livePositionsViewModel.serviceStatus.observe(viewLifecycleOwner){ status -> //if service is active, update the bus positions icon when(status) { LivePositionsServiceStatus.OK -> setBusPositionsIcon(true, error = false) LivePositionsServiceStatus.NO_POSITIONS -> setBusPositionsIcon(true, error = true) else -> setBusPositionsIcon(true, error = true) } } livePositionsViewModel.useMQTTPositionsLiveData.observe(viewLifecycleOwner){ useMQTT-> if (showBusLayer && isResumed) { if(useMQTT!=usingMQTTPositions){ // we have to switch val clearPos = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("positions_clear_on_switch_pref", true) livePositionsViewModel.clearOldPositionsUpdates() if(useMQTT){ //switching to MQTT, the GTFS positions are disabled automatically livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) } else{ //switching to GTFS RT: stop Mato, launch first request livePositionsViewModel.stopMatoUpdates() livePositionsViewModel.requestGTFSUpdates() } if (clearPos) { livePositionsViewModel.clearAllPositions() //force clear of the viewed data clearAllBusPositionsInMap() } } } usingMQTTPositions = useMQTT } Log.d(DEBUG_TAG, "Fragment View Created!") //TODO: Reshow last open stop when switching back to the map fragment return rootView } /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady val context = requireContext() val mjson = MapLibreStyles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(style) initMapLocation(style, mapReady, requireContext()) //init stop layer with this val stopsInCache = stopsViewModel.getAllStopsLoaded() if(stopsInCache.isEmpty()) initStopsLayer(style, FeatureCollection.fromFeatures(ArrayList())) else displayStops(stopsInCache) if(showBusLayer) setupBusLayer(style) // Start observing data now that everything is set up observeStops() } mapReady.addOnCameraIdleListener { isUserMovingCamera = false map?.let { val newBbox = it.projection.visibleRegion.latLngBounds if ((newBbox.center==lastBBox.center) && (newBbox.latitudeSpan==lastBBox.latitudeSpan) && (newBbox.longitudeSpan==lastBBox.latitudeSpan)){ //do nothing } else { stopsViewModel.loadStopsInLatLngBounds(newBbox) lastBBox = newBbox } } } mapReady.addOnCameraMoveStartedListener { v-> if(v== MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE){ //the user is moving the map isUserMovingCamera = true } map?.let { setFollowingUser(it.locationComponent.cameraMode == CameraMode.TRACKING) } //setFollowingUser() } mapReady.addOnMapClickListener { point -> onMapClickReact(point) } mapInitCompleted = true // we start requesting the bus positions now observeBusPositionUpdates() //Restoring data var boundsRestored = false pendingLocationActivation = true stopsViewModel.savedState?.let{ boundsRestored = restoreMapStateFromBundle(it) //why are we disabling it? pendingLocationActivation = it.getBoolean(KEY_LOCATION_ENABLED,true) Log.d(DEBUG_TAG, "Restored map state from the saved bundle: ") } if(pendingLocationActivation) positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) //reset saved State at the end if((!boundsRestored)) { //set initial position //center position val latlngTarget = initialStopToShow?.let { LatLng(it.latitude!!, it.longitude!!) } ?: LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) mapReady.cameraPosition = CameraPosition.Builder().target(latlngTarget).zoom(DEFAULT_ZOOM).build() } //reset saved state stopsViewModel.savedState = null } private fun onMapClickReact(point: LatLng): Boolean{ map?.let { mapReady -> val screenPoint = mapReady.projection.toScreenLocation(point) val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) if (features.isNotEmpty()) { val feature = features[0] val id = feature.getStringProperty("id") val name = feature.getStringProperty("name") //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = stopsViewModel.getStopByID(id) stop?.let { newstop -> val sameStopClicked = shownStopInBottomSheet?.let { newstop.ID==it.ID } ?: false if (isBottomSheetShowing) { hideStopBottomSheet() } if(!sameStopClicked){ openStopInBottomSheet(newstop) //isBottomSheetShowing = true //move camera if (newstop.latitude != null && newstop.longitude != null) //mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(it.latitude!!, it.longitude!!)).build() mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(newstop.latitude!!, newstop.longitude!!)), 750 ) } } return true } else if (busNearby.isNotEmpty()) { val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") Toast.makeText(context, "Veh $vehid on route $route", Toast.LENGTH_SHORT).show() return true } } return false } private fun initStopsLayer(style: Style, features:FeatureCollection){ stopsSource = GeoJsonSource(STOPS_SOURCE_ID,features) style.addSource(stopsSource) // add icon style.addImage(STOP_IMAGE_ID, ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!!) style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) style.addImage("ball",ResourcesCompat.getDrawable(resources, R.drawable.ball, activity?.theme)!!) // Stops layer val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) stopsLayer.withProperties( PropertyFactory.iconImage(STOP_IMAGE_ID), PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true) ) style.addLayerBelow(stopsLayer, "symbol-transit-airfield") //"label_country_1") this with OSM Bright selectedStopSource = GeoJsonSource(SEL_STOP_SOURCE, FeatureCollection.fromFeatures(ArrayList())) style.addSource(selectedStopSource) val selStopLayer = SymbolLayer(SEL_STOP_LAYER, SEL_STOP_SOURCE) selStopLayer.withProperties( PropertyFactory.iconImage(STOP_ACTIVE_IMG), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), ) style.addLayerAbove(selStopLayer, STOPS_LAYER_ID) stopsLayerStarted = true } /** * Setup the Map Layers */ private fun setupBusLayer(style: Style) { // Buses source busesSource = GeoJsonSource(BUSES_SOURCE_ID) style.addSource(busesSource) style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage("bus_symbol"), PropertyFactory.iconSize(1.2f), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP), PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), PropertyFactory.textAllowOverlap(true), PropertyFactory.textField(Expression.get("line")), PropertyFactory.textColor(Color.WHITE), PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), PropertyFactory.textSize(12f), PropertyFactory.textFont(arrayOf("noto_sans_regular")) ) } style.addLayerAbove(busesLayer, STOPS_LAYER_ID) //Line names layer /*vehiclesLabelsSource = GeoJsonSource(LABELS_SOURCE) style.addSource(vehiclesLabelsSource) val textLayer = SymbolLayer(LABELS_LAYER_ID, LABELS_SOURCE).apply { withProperties( PropertyFactory.textField("label"), PropertyFactory.textSize(30f), //PropertyFactory.textHaloColor(Color.BLACK), //PropertyFactory.textHaloWidth(1f), PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), PropertyFactory.textAllowOverlap(true), PropertyFactory.textField(Expression.get("line")), PropertyFactory.textColor(Color.WHITE), PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), PropertyFactory.textSize(12f) ) } style.addLayerAbove(textLayer, BUSES_LAYER_ID) */ } /** * Update the bottom sheet with the stop information */ override fun openStopInBottomSheet(stop: Stop){ bottomLayout?.let { //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" val stopName = stop.stopUserName ?: stop.stopDefaultName stopTitleTextView.text = stopName//stop.stopDefaultName stopNumberTextView.text = getString(R.string.stop_fill,stop.ID) val string_show = if (stop.numRoutesStopping==0) "" else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show //SET ON CLICK LISTENER arrivalsCard.setOnClickListener{ fragmentListener?.requestArrivalsForStopID(stop.ID) } directionsCard.setOnClickListener { ViewUtils.openStopInOutsideApp(stop, context) } } //add stop marker if (stop.latitude!=null && stop.longitude!=null) { /*stopActiveSymbol = symbolManager.create( SymbolOptions() .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) .withIconImage(STOP_ACTIVE_IMG) .withIconAnchor(ICON_ANCHOR_CENTER) //.withTextFont(arrayOf("noto_sans_regular"))) */ Log.d(DEBUG_TAG, "Showing stop: ${stop.ID}") val list = ArrayList() list.add(stopToGeoJsonFeature(stop)) selectedStopSource.setGeoJson( FeatureCollection.fromFeatures(list) ) } shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED isBottomSheetShowing = true } override fun onAttach(context: Context) { super.onAttach(context) fragmentListener = if (context is CommonFragmentListener) { context } else { throw RuntimeException( context.toString() + " must implement FragmentListenerMain" ) } } override fun onDetach() { super.onDetach() fragmentListener = null } override fun onStart() { super.onStart() - mapView.onStart() - //restore state from viewModel stopsViewModel.savedState?.let { restoreMapStateFromBundle(it) //reset state stopsViewModel.savedState = null } } override fun onResume() { super.onResume() - mapView.onResume() + //mapView.onResume() handled in GeneralMapLibreFragment //val keySourcePositions = getString(R.string.pref_positions_source) if(showBusLayer) { //first, clean up all the old positions livePositionsViewModel.clearOldPositionsUpdates() if (livePositionsViewModel.useMQTTPositionsLiveData.value!!){ livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) usingMQTTPositions = true } else { livePositionsViewModel.requestGTFSUpdates() usingMQTTPositions = false } //mapViewModel.testCascade(); livePositionsViewModel.isLastWorkResultGood.observe(this) { d: Boolean -> Log.d( DEBUG_TAG, "Last trip download result is $d" ) } livePositionsViewModel.tripsGtfsIDsToQuery.observe(this) { dat: List -> Log.i(DEBUG_TAG, "Have these trips IDs missing from the DB, to be queried: $dat") livePositionsViewModel.downloadTripsFromMato(dat) } } fragmentListener?.readyGUIfor(FragmentKind.MAP) //restore saved state savedMapStateOnPause?.let { restoreMapStateFromBundle(it) } } override fun onPause() { super.onPause() mapView.onPause() Log.d(DEBUG_TAG, "Fragment paused") savedMapStateOnPause = saveMapStateInBundle() if (livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.stopMatoUpdates() } override fun onStop() { super.onStop() mapView.onStop() Log.d(DEBUG_TAG, "Fragment stopped!") stopsViewModel.savedState = Bundle().let { mapView.onSaveInstanceState(it) it } //save last location map?.locationComponent?.lastKnownLocation?.let{ stopsViewModel.lastUserLocation = it } } - override fun onDestroy() { - super.onDestroy() - mapView.onDestroy() - Log.d(DEBUG_TAG, "Destroyed map Fragment!!") - } - override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) - map?.locationComponent?.isLocationComponentEnabled = false + //map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return mapView } private fun observeStops() { // Observe stops stopsViewModel.stopsToShow.observe(viewLifecycleOwner) { stops -> stopsShowing = ArrayList(stops) displayStops(stopsShowing) initialStopToShow?.let{ s-> //show the stop in the bottom sheet if(!initialStopShown && (s.ID in stopsShowing.map { it.ID })) { openStopInBottomSheet(s) initialStopShown = true } } } } /** * Add the stops to the layers */ private fun displayStops(stops: List?) { if (stops.isNullOrEmpty()) return if (stops.size==lastStopsSizeShown){ Log.d(DEBUG_TAG, "Not updating, have same number of stops. After 3 times") return } /*if(stops.size> lastStopsSizeShown){ stopsRedrawnTimes = 0 } else{ stopsRedrawnTimes++ } */ val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (s in stops){ if (s.latitude!=null && s.longitude!=null) features.add(stopToGeoJsonFeature(s)) } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") // if the layer is already started, substitute the stops inside, otherwise start it if (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initStopsLayer(mapStyle, FeatureCollection.fromFeatures(features)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } } // Hide the bottom sheet and remove extra symbol private fun hideStopBottomSheet(){ /*if (stopActiveSymbol!=null){ symbolManager.delete(stopActiveSymbol) stopActiveSymbol = null } */ //empty the source selectedStopSource.setGeoJson(FeatureCollection.fromFeatures(ArrayList())) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN //remove initial stop if(initialStopToShow!=null){ initialStopToShow = null } //set showing isBottomSheetShowing = false shownStopInBottomSheet = null } // --------------- BUS LOCATIONS STUFF -------------------------- /** * Start requesting position updates */ private fun observeBusPositionUpdates() { livePositionsViewModel.updatesWithTripAndPatterns.observe(viewLifecycleOwner) { data: HashMap> -> Log.d( DEBUG_TAG, "Have " + data.size + " trip updates, has Map start finished: " + mapInitCompleted ) if (mapInitCompleted) updateBusPositionsInMap(data) if (!isDetached && !livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.requestDelayedGTFSUpdates( 3000 ) } } private fun isInsideVisibleRegion(latitude: Double, longitude: Double, nullValue: Boolean): Boolean{ var isInside = nullValue val visibleRegion = map?.projection?.visibleRegion visibleRegion?.let { val bounds = it.latLngBounds isInside = bounds.contains(LatLng(latitude, longitude)) } return isInside } /*private fun createLabelForVehicle(positionUpdate: LivePositionUpdate){ val symOpt = SymbolOptions() .withLatLng(LatLng(positionUpdate.latitude, positionUpdate.longitude)) .withTextColor("#ffffff") .withTextField(positionUpdate.routeID.substringBeforeLast('U')) .withTextSize(13f) .withTextAnchor(TEXT_ANCHOR_CENTER) .withTextFont(arrayOf( "noto_sans_regular"))//"noto_sans_regular", "sans-serif")) //"noto_sans_regular")) val newSymbol = symbolManager.create(symOpt ) Log.d(DEBUG_TAG, "Symbol for veh ${positionUpdate.vehicle}: $newSymbol") busLabelSymbolsByVeh[positionUpdate.vehicle] = newSymbol } private fun removeVehicleLabel(vehicle: String){ busLabelSymbolsByVeh[vehicle]?.let { symbolManager.delete(it) busLabelSymbolsByVeh.remove(vehicle) } } */ /** * Update function for the bus positions * Takes the processed updates and saves them accordingly */ private fun updateBusPositionsInMap(incomingData: HashMap>){ val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) val vehsOld = HashSet(positionsByVehDict.keys) val symbolsToUpdate = ArrayList() for (upsWithTrp in incomingData.values){ val newPos = upsWithTrp.first val vehID = newPos.vehicle //var animate = false if (vehsOld.contains(vehID)){ //update position only if the starting or the stopping position of the animation are in the view val oldPos = positionsByVehDict[vehID] var avoidShowingUpdateBecauseIsImpossible = false oldPos?.let{ if(oldPos.routeID!=newPos.routeID) { val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(newPos.latitude, newPos.longitude)) val speed = dist*3.6 / (newPos.timestamp - it.timestamp) //this should be in km/h Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${newPos.routeID}, distance: $dist, speed: $speed") if (speed > 120 || speed < 0){ avoidShowingUpdateBecauseIsImpossible = true } } } if (avoidShowingUpdateBecauseIsImpossible){ // DO NOT SHOW THIS SHIT Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") continue } val samePosition = oldPos?.let { (oldPos.latitude==newPos.latitude)&&(oldPos.longitude == newPos.longitude) }?:false if(!samePosition) { val isPositionInBounds = isInsideVisibleRegion( newPos.latitude, newPos.longitude, false ) || (oldPos?.let { isInsideVisibleRegion(it.latitude,it.longitude, false) } ?: false) if ((newPos.bearing==null && oldPos?.bearing!=null)){ //copy old bearing newPos.bearing = oldPos.bearing } if (isPositionInBounds) { //animate = true //this moves both the icon and the label moveVehicleToNewPosition(newPos) } else { positionsByVehDict[vehID] = newPos /*busLabelSymbolsByVeh[vehID]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } } } else if(newPos.latitude>0 && newPos.longitude>0) { //we should not have to check for this // update it simply positionsByVehDict[vehID] = newPos //createLabelForVehicle(pos) }else{ Log.w(DEBUG_TAG, "Update ignored for veh $vehID on line ${newPos.routeID}, lat: ${newPos.latitude}, lon ${newPos.longitude}") } } // symbolManager.update(symbolsToUpdate) //remove old positions vehsOld.removeAll(vehsNew) //now vehsOld contains the vehicles id for those that have NOT been updated val currentTimeStamp = System.currentTimeMillis() /1000 for(vehID in vehsOld){ //remove after 2 minutes of inactivity if (positionsByVehDict[vehID]!!.timestamp - currentTimeStamp > 2*60){ positionsByVehDict.remove(vehID) //removeVehicleLabel(vehID) } } //finally, update UI updatePositionsIcons() } /** * This is the tricky part, animating the transitions * Basically, we need to set the new positions with the data and redraw them all */ private fun moveVehicleToNewPosition(positionUpdate: LivePositionUpdate){ if (positionUpdate.vehicle !in positionsByVehDict.keys) return val vehID = positionUpdate.vehicle val currentUpdate = positionsByVehDict[positionUpdate.vehicle] currentUpdate?.let { it -> //cancel current animation on vehicle animatorsByVeh[vehID]?.cancel() val currentPos = LatLng(it.latitude, it.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) val valueAnimator = ValueAnimator.ofObject(MapLibreUtils.LatLngEvaluator(), currentPos, newPos) valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { private var latLng: LatLng? = null override fun onAnimationUpdate(animation: ValueAnimator) { latLng = animation.animatedValue as LatLng //update position on animation val update = positionsByVehDict[positionUpdate.vehicle]!! latLng?.let { ll-> update.latitude = ll.latitude update.longitude = ll.longitude updatePositionsIcons() } } }) valueAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator) { super.onAnimationStart(animation) //val update = positionsByVehDict[positionUpdate.vehicle]!! //remove the label at the start of the animation /*val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 0.0f symbolsToUpdate.add(sym) } */ } override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) //recreate the label at the end of the animation //createLabelForVehicle(positionUpdate) /*val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 1.0f sym.latLng = newPos //LatLng(newPos) symbolsToUpdate.add(sym) } */ } }) //set the new position as the current one but with the old lat and lng positionUpdate.latitude = currentUpdate.latitude positionUpdate.longitude = currentUpdate.longitude positionsByVehDict[vehID] = positionUpdate valueAnimator.duration = 300 valueAnimator.interpolator = LinearInterpolator() valueAnimator.start() animatorsByVeh[vehID] = valueAnimator } ?: { Log.e(DEBUG_TAG, "Have to run animation for veh ${positionUpdate.vehicle} but not in the dict, adding") positionsByVehDict[positionUpdate.vehicle] = positionUpdate } } /** * Update the bus positions displayed on the map, from the existing data */ private fun updatePositionsIcons(){ //avoid frequent updates val currentTime = System.currentTimeMillis() //throttle updates when user is moving camera val interval = if(isUserMovingCamera) 150 else 60 val shouldDelayUpdateDraw = currentTime - lastUpdateTime < interval if(shouldDelayUpdateDraw){ //Defer map update viewLifecycleOwner.lifecycleScope.launch { delay(200) updatePositionsIcons() } return } val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (pos in positionsByVehDict.values){ //if (s.latitude!=null && s.longitude!=null) val point = Point.fromLngLat(pos.longitude, pos.latitude) features.add( Feature.fromGeometry( point, JsonObject().apply { addProperty("veh", pos.vehicle) addProperty("trip", pos.tripID) addProperty("bearing", pos.bearing ?:0.0f) addProperty("line", pos.routeID.substringBeforeLast('U')) } ) ) /*busLabelSymbolsByVeh[pos.vehicle]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } //this updates the positions busesSource.setGeoJson(FeatureCollection.fromFeatures(features)) //update labels, clear cache to be used //symbolManager.update(symbolsToUpdate) symbolsToUpdate.clear() lastUpdateTime = System.currentTimeMillis() } // ------ LOCATION STUFF ----- @SuppressLint("MissingPermission") private fun requestInitialUserLocation() { val provider : String = LocationManager.GPS_PROVIDER//getBestLocationProvider() //provider.let { setLocationIconEnabled(true) Toast.makeText(requireContext(), R.string.position_searching_message, Toast.LENGTH_SHORT).show() locationManager.requestSingleUpdate(provider, object : LocationListener { override fun onLocationChanged(location: Location) { val userLatLng = LatLng(location.latitude, location.longitude) val distanceToTarget = userLatLng.distanceTo(DEFAULT_LATLNG) if (distanceToTarget <= MAX_DIST_KM*1000.0) { map?.let{ // if we are still waiting for the position to enable if(pendingLocationActivation) setMapLocationEnabled(true, true, false) } } else { Toast.makeText(context, R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show() setMapLocationEnabled(false,false, false) } } override fun onProviderDisabled(provider: String) {} override fun onProviderEnabled(provider: String) {} @Deprecated("Deprecated in Java") override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} }, null) } /** * Clear all buses from the map */ private fun clearAllBusPositionsInMap(){ for ((k, anim) in animatorsByVeh){ anim.cancel() } animatorsByVeh.clear() positionsByVehDict.clear() updatePositionsIcons() } /** * Initialize the map location, but do not enable the component */ @SuppressLint("MissingPermission") private fun initMapLocation(style: Style, map: MapLibreMap, context: Context){ locationComponent = map.locationComponent val locationComponentOptions = LocationComponentOptions.builder(context) .pulseEnabled(true) .build() val locationComponentActivationOptions = MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) locationComponent.activateLocationComponent(locationComponentActivationOptions) locationComponent.isLocationComponentEnabled = false lastLocation?.let { if (it.accuracy < 200) locationComponent.forceLocationUpdate(it) } } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions, fromClick: $fromClick") locationComponent.isLocationComponentEnabled = true if (initialStopToShow==null) { locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setFollowingUser(true) } setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() pendingLocationActivation =false //locationComponent.locationEngine.requestLocationUpdates() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setFollowingUser(false) setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() if(pendingLocationActivation) pendingLocationActivation=false //Cancel the request for the enablement of the position } } } private fun setLocationIconEnabled(enabled: Boolean){ if (enabled) showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Helper method for GUI */ private fun setBusPositionsIcon(enabled: Boolean, error: Boolean){ val ctx = requireContext() if(!enabled) busPositionsIconButton.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.bus_pos_circle_inactive)) else if(error) busPositionsIconButton.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.bus_pos_circle_notworking)) else busPositionsIconButton.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.bus_pos_circle_active)) } private fun updateFollowingIcon(enabled: Boolean){ if(enabled) followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.walk_circle_active)) else followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.walk_circle_inactive)) } private fun setFollowingUser(following: Boolean){ updateFollowingIcon(following) followingUserLocation = following if(following) ignoreCameraMovementForFollowing = true } private fun switchUserLocationStatus(view: View?){ if(pendingLocationActivation || locationComponent.isLocationComponentEnabled) setMapLocationEnabled(false, false, true) else{ pendingLocationActivation = true Log.d(DEBUG_TAG, "Request enable location") setMapLocationEnabled(true, false, true) } } companion object { private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" private const val STOPS_LAYER_SEL_ID ="stops-layer-selected" private const val LABELS_LAYER_ID = "bus-labels-layer" private const val LABELS_SOURCE = "labels-source" private const val STOP_IMAGE_ID ="bus-stop-icon" const val DEFAULT_CENTER_LAT = 45.0708 const val DEFAULT_CENTER_LON = 7.6858 private val DEFAULT_LATLNG = LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) private val DEFAULT_ZOOM = 14.3 private const val POSITION_FOUND_ZOOM = 16.5 private const val NO_POSITION_ZOOM = 17.1 private const val MAX_DIST_KM = 90.0 private const val DEBUG_TAG = "BusTO-MapLibreFrag" private const val STOP_ACTIVE_IMG = "Stop-active" const val FRAGMENT_TAG = "BusTOMapFragment" private const val LOCATION_PERMISSION_REQUEST_CODE = 981202 /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @param stop Eventual stop to center the map into * @return A new instance of fragment MapLibreFragment. */ @JvmStatic fun newInstance(stop: Stop?) = MapLibreFragment().apply { arguments = Bundle().let { // Cannot use Parcelable as it requires higher version of Android //stop?.let{putParcelable(STOP_TO_SHOW, it)} stop?.toBundle(it) } } } } \ No newline at end of file