diff --git a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/LivePositionUpdate.kt b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/LivePositionUpdate.kt index d6728e5..4f304a5 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/LivePositionUpdate.kt +++ b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/LivePositionUpdate.kt @@ -1,66 +1,71 @@ /* BusTO - Backend 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.backend.gtfs import com.google.transit.realtime.GtfsRealtime.VehiclePosition /** * General data class for the live position update * Used in both the GTFS and MaTO services */ data class LivePositionUpdate( val tripID: String, //tripID WITHOUT THE "gtt:" prefix val startTime: String?, val startDate: String?, val routeID: String, // routeID DOES NOT HAVE THE "gtt:" PREFIX val vehicle: String, var latitude: Double, var longitude: Double, var bearing: Float?, //the timestamp IN SECONDS val timestamp: Long, val nextStop: String?, /*val vehicleInfo: VehicleInfo, val occupancyStatus: OccupancyStatus?, val scheduleRelationship: ScheduleRelationship? */ //var tripInfo: TripAndPatternWithStops?, ){ constructor(position: VehiclePosition) : this( position.trip.tripId, position.trip.startTime, position.trip.startDate, position.trip.routeId, position.vehicle.label, position.position.latitude.toDouble(), position.position.longitude.toDouble(), position.position.bearing, position.timestamp, null ) fun getLineGTFSFormat(): String{ return "gtt:$routeID" } + + fun hasTripId(): Boolean{ + val r = tripID.isEmpty() || tripID == "null" + return !r + } } 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 203372d..915b4f3 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,1231 +1,1261 @@ /* BusTO - Fragments components Copyright (C) 2025 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.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.Context.LOCATION_SERVICE import android.content.SharedPreferences import android.content.res.ColorStateList import android.graphics.Color import android.location.Location import android.location.LocationManager import android.os.Bundle import android.util.Log import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.LinearInterpolator import android.widget.ImageButton import android.widget.ImageView 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.constraintlayout.widget.ConstraintLayout 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.Lifecycle import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.BuildConfig import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.LivePositionTripPattern import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.Stop +import it.reyboz.bustorino.backend.VehicleClassInfo import it.reyboz.bustorino.backend.VehicleUtils import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.MapLibreLocationEngine import it.reyboz.bustorino.map.MapLibreUtils import it.reyboz.bustorino.middleware.FusedNativeLocationProvider import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import it.reyboz.bustorino.viewmodels.MapStateViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.maplibre.android.MapLibre import org.maplibre.android.camera.CameraPosition import org.maplibre.android.geometry.LatLng import org.maplibre.android.location.LocationComponent import org.maplibre.android.location.LocationComponentActivationOptions import org.maplibre.android.location.engine.LocationEngineCallback import org.maplibre.android.location.engine.LocationEngineRequest import org.maplibre.android.location.engine.LocationEngineResult 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.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.Property.ICON_ANCHOR_CENTER import org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP import org.maplibre.android.style.layers.Property.TEXT_ANCHOR_CENTER import org.maplibre.android.style.layers.Property.TEXT_ROTATION_ALIGNMENT_VIEWPORT 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 import kotlin.time.Duration.Companion.milliseconds abstract class GeneralMapLibreFragment: ScreenBaseFragment(), OnMapReadyCallback { protected var map: MapLibreMap? = null protected var shownStopInBottomSheet : Stop? = null //protected var savedMapStateOnPause : Bundle? = null protected var fragmentListener: CommonFragmentListener? = null // Declare a variable for MapView protected var mapView: MapView? = null protected lateinit var mapStyle: Style protected lateinit var stopsSource: GeoJsonSource protected lateinit var busesSource: GeoJsonSource protected lateinit var selectedStopSource: GeoJsonSource protected lateinit var selectedBusSource: GeoJsonSource //= GeoJsonSource(SEL_BUS_SOURCE) protected lateinit var sharedPreferences: SharedPreferences protected lateinit var bottomSheetBehavior: BottomSheetBehavior protected var locationEngine: MapLibreLocationEngine? = null protected var locationProvider: FusedNativeLocationProvider? = null protected var shownToastNoPosition = false protected var locationEnabledOnDevice = true protected var busLayerStarted = false //TODO ACTIVATE THIS 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() } } /** * What to do when requesting the permission, when it's ok, initialize the map location component */ protected val positionRequestResponder = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback{ res -> if(!(res.containsKey(PERM_LOC_COARSE)&&res.containsKey(PERM_LOC_FINE))){ Log.e(DEBUG_TAG, "Location request does not have the correct keys") } else if(res[PERM_LOC_COARSE]!! && res[PERM_LOC_FINE]!!){ //permission OK, init map location val mMap = map if(mMap == null){ Log.w(DEBUG_TAG, "Location request completed, but map is null!") }else{ initializeMapLocationComponent(mMap,requireContext(), null) } } else{ // PERMISSION DENIED // TODO find better way to show the necessity of the permission if(shouldShowRequestPermissionRationale(PERM_LOC_FINE)) Toast.makeText(requireContext(), R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } } ) //Bottom sheet behavior in GeneralMapLibreFragment protected var bottomLayout: ConstraintLayout? = null protected lateinit var stopTitleTextView: TextView protected lateinit var stopNumberTextView: TextView protected lateinit var linesPassingTextView: TextView protected lateinit var extraBottomTextView: TextView protected lateinit var linesBottomTextView: TextView protected lateinit var arrivalsCard: CardView protected lateinit var directionsCard: CardView protected lateinit var bottomrightImage: ImageView protected lateinit var locationComponent: LocationComponent protected lateinit var busPositionsIconButton: ImageButton protected lateinit var vehicleIcon: ImageView + protected lateinit var warningTripIcon: ImageView + private lateinit var loadingTripIcon: ImageView protected var lastLocation : Location? = null private var lastMapStyle ="" //BUS POSITIONS protected val updatesByVehDict = HashMap(5) protected val animatorsByVeh = HashMap() protected var vehShowing: String? = null protected var lastUpdateTime:Long = -2 protected var jobUpdate: Job? = null //private val lifecycleOwnerLiveData = viewLifecycleOwnerLiveData //extra items to use the LibreMap protected var symbolManager : SymbolManager? = null protected var stopActiveSymbol: Symbol? = null protected var stopsLayerStarted = false protected val livePositionsViewModel : LivePositionsViewModel by activityViewModels() //private lateinit var symbolManager: SymbolManager protected val mapStateViewModel: MapStateViewModel by viewModels() protected var locationInitialized = false protected var mapInitialized = false protected var receivedFirstLocation = false //location callback to decide if to zoom to the user position @SuppressLint("MissingPermission") protected val mapLibreLocationCallback = object : LocationEngineCallback { override fun onSuccess(result: LocationEngineResult) { val location: Location? = result.lastLocation Log.d(DEBUG_TAG, "Received location $location") location?.let { //check timing of the location val currentTime = System.currentTimeMillis() val discard = (currentTime - it.time) > 90 * 1000.0 // discard if it is Older than 60 seconds if(!discard) { if (!receivedFirstLocation) { onFirstReceivedLocation(it) } receivedFirstLocation = true } } if(receivedFirstLocation){ //remove this listener once we have received the location locationEngine?.removeLocationUpdates(this) } } override fun onFailure(exception: Exception) { Log.e(DEBUG_TAG, "Error in getting position: ${exception.message}") } } protected val deviceLocationStatusListener = FusedNativeLocationProvider.LocationStatusListener { isEnabled -> mapStateViewModel.locationDeviceEnabled.value = isEnabled if(locationEnabledOnDevice && !isEnabled && locationInitialized) { warnLocationNotEnabledOnDevice() //setMapLocationEnabled(false) } locationEnabledOnDevice = isEnabled } 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? { //TODO: Re-create map when this preference changes lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onCreateView lastMapStyle: $lastMapStyle") return super.onCreateView(inflater, container, savedInstanceState) } protected fun initBottomSheet(view: View){ val bottomSheet = view.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = view.findViewById(R.id.stopTitleTextView) stopNumberTextView = view.findViewById(R.id.stopNumberTextView) linesPassingTextView = view.findViewById(R.id.descriptionTextView) arrivalsCard = view.findViewById(R.id.arrivalsCardButton) directionsCard = view.findViewById(R.id.directionsCardButton) vehicleIcon = view.findViewById(R.id.vehicleIcon) + loadingTripIcon = view.findViewById(R.id.downloadingIcon) + warningTripIcon = view.findViewById(R.id.warningIconTrip) linesBottomTextView = view.findViewById(R.id.linesBottomTextView) linesBottomTextView.text = getString(R.string.lines_fill, "") bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + //set onclick listener for warning trip icon + warningTripIcon.setOnClickListener { + showToastMessage(R.string.no_trip_info_warning, true) + } + loadingTripIcon.setOnClickListener { showToastMessage(R.string.downloading_trip_info, true) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) //init bottom sheet initBottomSheet(view) bottomrightImage = view.findViewById(R.id.rightmostImageView) extraBottomTextView = view.findViewById(R.id.extraBottomTextView) } override fun onResume() { super.onResume() mapView?.onResume() val newMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onResume newMapStyle: $newMapStyle, lastMapStyle: $lastMapStyle") // TODO: reload style if user changed preferences //if(newMapStyle!=lastMapStyle){ // reloadMap() //} if(busLayerStarted) updatePositionsIcons(false) } override fun onLowMemory() { mapView?.onLowMemory() super.onLowMemory() } override fun onStart() { super.onStart() mapView?.onStart() } override fun onDestroy() { mapView?.onDestroy() Log.d(DEBUG_TAG, "Destroyed mapView Fragment!!") busLayerStarted = false super.onDestroy() } override fun onStop() { locationProvider?.removeListener(deviceLocationStatusListener) mapView?.onStop() super.onStop() } override fun onPause() { jobUpdate?.cancel() mapView?.onPause() super.onPause() } override fun onDestroyView() { bottomLayout = null mapInitialized = false locationInitialized = false super.onDestroyView() } protected fun warnLocationNotEnabledOnDevice(){ context?.let{ Toast.makeText(it,R.string.enable_location_message,Toast.LENGTH_SHORT).show() } } 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) } */ } //For extra stuff to do when the map is destroyed abstract fun onMapDestroy() override fun onAttach(context: Context) { super.onAttach(context) if(context is CommonFragmentListener){ fragmentListener = context } else throw RuntimeException("$context must implement CommonFragmentListener") } 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) } protected 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){ hideStopOrBusBottomSheet() } } } // Hide the bottom sheet and remove extra symbol protected open fun hideStopOrBusBottomSheet(){ if (stopActiveSymbol!=null){ symbolManager?.delete(stopActiveSymbol) stopActiveSymbol = null } if(!showOpenStopWithSymbolLayer()){ selectedStopSource.setGeoJson(FeatureCollection.fromFeatures(ArrayList())) } bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN //isBottomSheetShowing = false //reset states shownStopInBottomSheet = null if (vehShowing!=null){ //we are hiding a vehicle vehShowing = null updatePositionsIcons(true) } extraBottomTextView.visibility = View.GONE } protected fun initSymbolManager(mapReady: MapLibreMap , style: Style){ val sm = SymbolManager(mapView!!, mapReady, style) sm.iconAllowOverlap = true sm.textAllowOverlap = false sm.addClickListener { _ -> if (stopActiveSymbol != null) { hideStopOrBusBottomSheet() return@addClickListener true } else return@addClickListener false } symbolManager = sm } /** * Change the icon indicating the status of the live Positions */ protected 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)) } abstract fun onMapLocationComponentInitialized() @SuppressLint("MissingPermission") protected fun setLocationComponentEnabled(enabled: Boolean): Boolean{ var changed = false map?.apply { if(locationComponent.isLocationComponentEnabled !=enabled) locationComponent.isLocationComponentEnabled= enabled changed = true} Log.d(DEBUG_TAG, "Asked to set location component enabled: $enabled, changed: $changed") mapStateViewModel.locationUserActive.value = enabled return changed } @SuppressLint("MissingPermission") protected fun initializeMapLocationComponent(map: MapLibreMap, context: Context, style: Style?){ val mStyle = style ?: map.style if(locationInitialized){ Log.w(DEBUG_TAG, "trying to initialize Location Component, but it is already done") return } mStyle?.let{ style -> locationComponent = map.locationComponent val locProvider = FusedNativeLocationProvider(context) locProvider.addListener(deviceLocationStatusListener) locationEngine = MapLibreLocationEngine(locProvider) locationProvider = locProvider val options = LocationComponentActivationOptions.builder(context, style) .useDefaultLocationEngine(false) .locationEngine(locationEngine) .build() locationComponent.activateLocationComponent(options) if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Initializing location, request initial position") startInitialPositionRequest() if(!locationEnabledOnDevice){ warnLocationNotEnabledOnDevice() }else { setLocationComponentEnabled(true) } locationInitialized = true onMapLocationComponentInitialized() } } @SuppressLint("MissingPermission") protected fun startInitialPositionRequest(){ locationEngine?.requestLocationUpdates(LocationEngineRequest.Builder(500).setDisplacement(20.0f).build(), mapLibreLocationCallback, null) } protected fun stopInitialPositionRequest(){ locationEngine?.removeLocationUpdates(mapLibreLocationCallback) } + protected fun showVehClassInfo(vehInfo: VehicleClassInfo){ + val print = "${vehInfo.type.getName()}: ${vehInfo.name}" + makeToast(print) + } + /** * Update function for the bus positions * Takes the processed updates and saves them accordingly * Unified version that works with both fragments * * @param incomingData Map of updates with optional trip and pattern information * @param hasVehicleTracking If true, checks if vehShowing is updated and calls callback (default: true) * @param trackVehicleCallback Optional callback to show vehicle details when vehShowing is updated */ protected fun updateBusPositionsInMap( incomingData: HashMap>, hasVehicleTracking: Boolean = true, trackVehicleCallback: ((String) -> Unit)? = null ) { + //TODO: Eventually change this (incomingData should be keyed by vehicle) 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 for (upsWithTrp in incomingData.values) { val newPos = upsWithTrp.first val patternStops = upsWithTrp.second val vehID = newPos.vehicle // Validate coordinates if (!vehsOld.contains(vehID)) { if (newPos.latitude <= 0 || newPos.longitude <= 0) { Log.w(DEBUG_TAG, "Update ignored for veh $vehID on line ${newPos.routeID}, lat: ${newPos.latitude}, lon ${newPos.longitude}") continue } } if (vehsOld.contains(vehID)) { // Changing the location of an existing bus val oldPosData = updatesByVehDict[vehID]!! val oldPos = oldPosData.posUpdate val oldPattern = oldPosData.pattern var avoidShowingUpdateBecauseIsImpossible = false // Check for impossible route changes if (oldPos.routeID != newPos.routeID) { val dist = LatLng(oldPos.latitude, oldPos.longitude).distanceTo( LatLng(newPos.latitude, newPos.longitude) ) val speed = dist * 3.6 / (newPos.timestamp - oldPos.timestamp) // 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) { Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") continue } // Check if position actually changed val samePosition = (oldPos.latitude == newPos.latitude) && (oldPos.longitude == newPos.longitude) val setPattern = (oldPattern == null) && (patternStops != null) // Copy old bearing if new one is missing if (newPos.bearing == null && oldPos.bearing != null) { newPos.bearing = oldPos.bearing } if (!samePosition || setPattern) { val newOrOldPosInBounds = isPointInsideVisibleRegion( newPos.latitude, newPos.longitude, true ) || isPointInsideVisibleRegion(oldPos.latitude, oldPos.longitude, true) if (newOrOldPosInBounds) { // Update pattern data if available patternStops?.let { updatesByVehDict[vehID]!!.pattern = it.pattern } // Animate the position change animateNewPositionMove(newPos) } else { // Update position without animation updatesByVehDict[vehID] = LivePositionTripPattern( newPos, patternStops?.pattern ) } } countUpds++ } else { // New vehicle - create entry updatesByVehDict[vehID] = LivePositionTripPattern( newPos, patternStops?.pattern ) createdVehs++ } // Update vehicle details if this is the shown/tracked vehicle if (hasVehicleTracking && vehShowing?.isNotEmpty() == true && vehID == vehShowing) { trackVehicleCallback?.invoke(vehID) } } // Remove old positions Log.d(DEBUG_TAG, "Updated $countUpds vehicles, created $createdVehs vehicles") vehsOld.removeAll(vehsNew) // Clean up stale vehicles (not updated for 2 minutes) val currentTimeStamp = System.currentTimeMillis() / 1000 for (vehID in vehsOld) { val posData = updatesByVehDict[vehID]!! if (currentTimeStamp - posData.posUpdate.timestamp > 2 * 60) { // Remove the bus updatesByVehDict.remove(vehID) // Cancel and remove animator if exists animatorsByVeh[vehID]?.cancel() animatorsByVeh.remove(vehID) } } // Update UI updatePositionsIcons(false) } /** * Shared bottom sheet setup. The [onDirectionsClick] lambda is called when * directionsCard is tapped; it receives the pattern code (empty string when * no pattern is available) so each subclass can navigate as it sees fit. */ protected fun showVehicleTripInBottomSheet( veh: String, onDirectionsClick: (patternCode: String, veh: String) -> Unit ) { val data = updatesByVehDict[veh] ?: run { 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), false ) val pat = data.pattern + val update = data.posUpdate + if (pat != null) { stopTitleTextView.text = pat.headsign stopTitleTextView.visibility = View.VISIBLE stopNumberTextView.text = getString(R.string.line_fill_towards, lineName) + loadingTripIcon.visibility = View.GONE } else { stopTitleTextView.visibility = View.GONE stopNumberTextView.text = getString(R.string.line_fill, lineName) + loadingTripIcon.visibility = if (update.hasTripId()) View.VISIBLE else View.GONE } directionsCard.setOnClickListener { onDirectionsClick(pat?.code ?: "", veh) } directionsCard.visibility = View.VISIBLE bottomrightImage.setImageDrawable( ResourcesCompat.getDrawable(resources, R.drawable.ic_magnifying_glass, activity?.theme) ) // if you change this, remember to change the color of the vehicleIcon val colorBlue = ResourcesCompat.getColor(resources, R.color.bus_marker_color_selected, activity?.theme) ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorBlue)) linesPassingTextView.text = getString(R.string.vehicle_fill, data.posUpdate.vehicle) linesPassingTextView.gravity = Gravity.CENTER_VERTICAL linesBottomTextView.visibility = View.GONE arrivalsCard.visibility = View.GONE extraBottomTextView.text = getString(R.string.updated_fill, utils.unixTimestampToLocalTime(data.posUpdate.timestamp)) extraBottomTextView.visibility = View.VISIBLE - val update = data.posUpdate val vehInfo = VehicleUtils.getTypeForLabel(update.vehicle) if(vehInfo == null){ vehicleIcon.visibility = View.GONE + linesPassingTextView.setOnClickListener { } //empty click listener } else{ val ico = when(vehInfo.type){ VehicleUtils.VehicleType.BUS -> R.drawable.ic_bus VehicleUtils.VehicleType.ELECTRIC_BUS -> R.drawable.ic_bus_electric_filled VehicleUtils.VehicleType.TRAM -> R.drawable.ic_tram_material } vehicleIcon.setImageDrawable(ResourcesCompat.getDrawable(resources, ico, activity?.theme)) vehicleIcon.visibility = View.VISIBLE vehicleIcon.setOnClickListener { - val print = "${vehInfo.type.getName()}: ${vehInfo.name}" - makeToast(print) + showVehClassInfo(vehInfo) } + linesPassingTextView.setOnClickListener { showVehClassInfo(vehInfo) } + } + if (!update.hasTripId()){ + warningTripIcon.visibility = View.VISIBLE + } else{ + warningTripIcon.visibility = View.GONE } } vehShowing = veh bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED updatePositionsIcons(true) Log.d(DEBUG_TAG, "Shown vehicle $veh in bottom sheet") } /** * Update the bus positions displayed on the map, from the existing data * * @param forced If true, forces immediate update ignoring the 100ms throttle */ protected fun updatePositionsIcons(forced: Boolean) { // Avoid frequent updates - throttle to max once per 60ms val currentTime = System.currentTimeMillis() val isStarted = (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) if(forced){ // if we're running a forced update, cancel the pending one jobUpdate?.apply{ cancel() //Log.d(DEBUG_TAG, "Cancelled update") } } else if (currentTime - lastUpdateTime < 100) { // Schedule delayed update if(viewLifecycleOwnerLiveData.value != null) { jobUpdate?.cancel() jobUpdate = viewLifecycleOwner.lifecycleScope.launch { delay(100.milliseconds) //Log.d(DEBUG_TAG, "Running update from delayed") updatePositionsIcons(false) } //Log.d(DEBUG_TAG, "Cancelled previous job, delaying update") } return } if(!isStarted){ Log.w(DEBUG_TAG, "fragment is not started, ") } val busFeatures = ArrayList() val selectedBusFeatures = ArrayList() for (dat in updatesByVehDict.values) { 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.substringBeforeLast('U')) } ) // Separate selected vehicle from others if (vehShowing?.isNotEmpty() == true && vehShowing == dat.posUpdate.vehicle) { selectedBusFeatures.add(newFeature) //Log.d(DEBUG_TAG, "Update position for bus $vehShowing") //TODO: Recenter the map on the vehicle } else { busFeatures.add(newFeature) } } busesSource.setGeoJson(FeatureCollection.fromFeatures(busFeatures)) selectedBusSource.setGeoJson(FeatureCollection.fromFeatures(selectedBusFeatures)) lastUpdateTime = System.currentTimeMillis() } /** * Animates the transition of a vehicle from its current position to a new position * This is the tricky part - we need to set the new positions with the data and redraw them all * * @param positionUpdate The new position update to animate to */ protected fun animateNewPositionMove(positionUpdate: LivePositionUpdate) { val vehID = positionUpdate.vehicle // Check if vehicle exists in our tracking dictionary if (vehID !in updatesByVehDict.keys) { return } val currentUpdate = updatesByVehDict[vehID] ?: run { Log.e(DEBUG_TAG, "Have to run animation for veh $vehID but not in the dict") return } // Cancel any current animation for this vehicle animatorsByVeh[vehID]?.cancel() val posUp = currentUpdate.posUpdate val currentPos = LatLng(posUp.latitude, posUp.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) // Create animator for smooth transition val valueAnimator = ValueAnimator.ofObject( MapLibreUtils.LatLngEvaluator(), currentPos, newPos ) valueAnimator.addUpdateListener { animation -> val latLng = animation.animatedValue as LatLng // Update position during animation updatesByVehDict[vehID]?.let { update -> update.posUpdate.latitude = latLng.latitude update.posUpdate.longitude = latLng.longitude updatePositionsIcons(false) } ?: run { Log.w(DEBUG_TAG, "The bus position to animate has been removed, but the animator is still running!") } } // Set the new position as current but keep old coordinates for animation start positionUpdate.latitude = posUp.latitude positionUpdate.longitude = posUp.longitude updatesByVehDict[vehID]!!.posUpdate = positionUpdate // Configure and start animation valueAnimator.duration = 300 valueAnimator.interpolator = LinearInterpolator() valueAnimator.start() // Store animator for potential cancellation animatorsByVeh[vehID] = valueAnimator } /// STOP OPENING abstract fun showOpenStopWithSymbolLayer(): Boolean /** * Update the bottom sheet with the stop information */ protected 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 stop.routesThatStopHereToString() //requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show linesPassingTextView.visibility = View.VISIBLE + linesPassingTextView.setOnClickListener { } //empty click listener (needed when switching from vehicle) + linesPassingTextView.gravity = Gravity.TOP linesBottomTextView.visibility =View.VISIBLE //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, 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)) - + // icons for the vehicles vehicleIcon.visibility = View.GONE + warningTripIcon.visibility = View.GONE + loadingTripIcon.visibility = View.GONE } //add stop marker if (stop.latitude!=null && stop.longitude!=null) { Log.d(DEBUG_TAG, "Showing stop: ${stop.ID}") if (showOpenStopWithSymbolLayer()) { stopActiveSymbol = symbolManager?.create( SymbolOptions() .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) .withIconImage(STOP_ACTIVE_IMG) .withIconAnchor(ICON_ANCHOR_CENTER) ) } else { val list = ArrayList() list.add(stopToGeoJsonFeature(stop)) selectedStopSource.setGeoJson( FeatureCollection.fromFeatures(list) ) } } Log.d(DEBUG_TAG, "Shown stop $stop in bottom sheet") shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED } protected fun stopAnimations(){ for(anim in animatorsByVeh.values){ anim.cancel() } } protected fun addImagesStyle(style: Style){ 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)!!) style.addImage(BUS_IMAGE_ID,ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) style.addImage(BUS_SEL_IMAGE_ID, ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon_sel, activity?.theme)!!) val polyIconArrow = ResourcesCompat.getDrawable(resources, R.drawable.arrow_up_box_fill, activity?.theme)!! style.addImage(POLY_ARROW, polyIconArrow) } protected fun initStopsLayer(style: Style, stopsFeatures: FeatureCollection?){ //determine default layer val layerAbove = if (lastMapStyle == MapLibreUtils.STYLE_OSM_RASTER){ "osm-raster" } else// if (lastMapStyle == MapLibreUtils.STYLE_VERSATILES_ECLIPSE_JSON){ "symbol-transit-airfield" /*} else { // "poi_park" } */ initStopsLayer(style, stopsFeatures, layerAbove) } protected fun initStopsLayer(style: Style, stopsFeatures: FeatureCollection?, stopsLayerAbove: String){ stopsSource = GeoJsonSource(STOPS_SOURCE_ID,stopsFeatures ?: FeatureCollection.fromFeatures(ArrayList())) style.addSource(stopsSource) // 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.addLayerAbove(stopsLayer, stopsLayerAbove ) //"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 */ protected fun setupBusLayer(style: Style, withLabels: Boolean =false, busIconsScale: Float = 1.0f) { // 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) // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage(BUS_IMAGE_ID), PropertyFactory.iconSize(busIconsScale), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) if (withLabels){ withProperties(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) val selectedBusLayer = SymbolLayer(SEL_BUS_LAYER, SEL_BUS_SOURCE).apply { withProperties( PropertyFactory.iconImage(BUS_SEL_IMAGE_ID), PropertyFactory.iconSize(busIconsScale), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) if (withLabels){ withProperties(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(selectedBusLayer, BUSES_LAYER_ID) busLayerStarted = true } /** * Method used for enabling / disabling the location from the buttons */ protected fun switchUserLocationStatus(view: View?){ val enabled = if(locationInitialized) locationComponent.isLocationComponentEnabled else false val context = context ?: return if(enabled) { if(!receivedFirstLocation){ //use case: the user has decided to disable the location before the first position arrived stopInitialPositionRequest() } // we have to disable it setMapLocationEnabled(false) } else if(deviceHasLocationProvider()) { if(Permissions.bothLocationPermissionsGranted(context)){ if(!locationEnabledOnDevice){ warnLocationNotEnabledOnDevice() } else{ setMapLocationEnabled(true) } } else{ Log.d(DEBUG_TAG, "Requesting permissions to show location") Permissions.getInstance(context).checkRequestLocationPermissions(requireActivity(), positionRequestResponder) } } else{ context.let { Toast.makeText(it, R.string.no_gps_on_device, Toast.LENGTH_SHORT).show() } //adjust ui setLocationIconEnabled(false) } } /** * Set the map location component enabled */ @SuppressLint("MissingPermission") protected fun setMapLocationEnabled(enabled: Boolean){ Log.d(DEBUG_TAG, "Setting map location enabled: $enabled") map?.locationComponent?.isLocationComponentEnabled = enabled //map?.cameraPosition = mapStateViewModel.locationUserActive.value = enabled onMapLocationEnabled(enabled) } /** * Function to run at the first time the fragment is opened * Check if we have the permissions, and then initialize the map location component * If we don't have it, request the permission */ protected fun checkInitMapLocation(mapReady: MapLibreMap,style: Style, context: Context) { //enable location val hasGps = deviceHasLocationProvider() val permissions = Permissions.getInstance(context) if(hasGps) { if (Permissions.bothLocationPermissionsGranted(context)) { Log.d(DEBUG_TAG, "Have got the location permission, init location component") initializeMapLocationComponent(mapReady, context, style) }else { var req = false activity?.let{ req = permissions.checkRequestLocationPermissions(it, positionRequestResponder) } if(!req) { setMapLocationEnabled(false) } } } } /** * Set the UI elements showing that the user location is disabled */ abstract fun onMapLocationEnabled(active: Boolean) /** * Helper function to actually set the icon */ abstract fun setLocationIconEnabled(enabled: Boolean) /** * Called when we receive the first fix on the user location */ abstract fun onFirstReceivedLocation(location: Location) protected fun isBottomSheetShowing(): Boolean { return bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED } protected fun deviceHasLocationProvider(): Boolean{ val locManager = requireContext().getSystemService(LOCATION_SERVICE) as LocationManager return locManager.allProviders.isNotEmpty() } /** * Update automatically the icon when the live position service changes status */ protected fun observeStatusLivePositions(){ 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) } } } /** * Clear all buses from the map */ protected fun clearAllBusPositionsInMap(){ for ((k, anim) in animatorsByVeh){ anim.cancel() } animatorsByVeh.clear() updatesByVehDict.clear() updatePositionsIcons(forced = false) } protected fun setCameraPosition(latitude: Double, longitude: Double, zoom: Double) { map?.cameraPosition = CameraPosition.Builder() .target(LatLng(latitude, longitude)) .zoom(zoom) .build() } protected fun showToastLocation(enabled: Boolean){ val textid = if (enabled) R.string.location_enabled else R.string.location_disabled context?.let{ Toast.makeText(it,textid,Toast.LENGTH_SHORT).show() } } 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 SEL_BUS_SOURCE = "sel_bus_source" const val SEL_BUS_LAYER = "sel_bus_layer" const val KEY_LOCATION_ENABLED="location_enabled" protected const val STOPS_SOURCE_ID = "stops-source" protected const val STOPS_LAYER_ID = "stops-layer" protected const val STOP_IMAGE_ID = "stop-img" protected const val STOP_ACTIVE_IMG = "stop_active_img" protected const val BUS_IMAGE_ID = "bus_symbol" protected const val BUS_SEL_IMAGE_ID = "sel_bus_symbol" protected const val POLYLINE_LAYER = "polyline-layer" protected const val POLYLINE_SOURCE = "polyline-source" protected const val POLY_ARROWS_LAYER = "arrows-layer" protected const val POLY_ARROWS_SOURCE = "arrows-source" protected const val POLY_ARROW ="poly-arrow-img" private const val PERM_LOC_COARSE = Manifest.permission.ACCESS_COARSE_LOCATION private const val PERM_LOC_FINE = Manifest.permission.ACCESS_FINE_LOCATION //TODO: this is hardcoded, make it modifiable by the user protected const val MAX_DIST_KM = 90.0 } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt index b8703e8..a3114ac 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt @@ -1,489 +1,496 @@ /* BusTO - ViewModel 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.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.* import androidx.preference.PreferenceManager import androidx.work.WorkInfo import androidx.work.WorkManager import com.android.volley.DefaultRetryPolicy import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.Fetcher import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.NetworkVolleyManager import it.reyboz.bustorino.backend.gtfs.GtfsRtPositionsRequest import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.mato.MQTTMatoClient import it.reyboz.bustorino.backend.mato.PositionsMap import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.MatoPatternsDownloadWorker import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.gtfs.MatoPattern import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* import kotlin.collections.ArrayList import kotlin.collections.HashMap import kotlin.collections.HashSet import androidx.core.content.edit import androidx.lifecycle.MutableLiveData import kotlin.text.contains typealias FullPositionUpdatesMap = HashMap> typealias FullPositionUpdate = Pair class LivePositionsViewModel(application: Application): AndroidViewModel(application) { private val gtfsRepo = GtfsRepository(application) //chain of LiveData objects: raw positions -> tripsIDs -> tripsAndPatternsInDB -> positions with patterns //this contains the raw positions updates received from the service private val positionsToBeMatchedLiveData = MutableLiveData>() private val netVolleyManager = NetworkVolleyManager.getInstance(application) private var mqttClient = MQTTMatoClient() private var lineListening = "" private var lastTimeMQTTUpdatedPositions: Long = 0 private val gtfsRtRequestRunning = MutableLiveData(false) private val lastFailedTripsRequest = HashMap() private val workManager = WorkManager.getInstance(application) private var lastRequestedDownloadTrips = MutableLiveData>() //INPUT FILTER FOR LINE private var gtfsLineToFilterPos = MutableLiveData>() var serviceStatus = MutableLiveData(LivePositionsServiceStatus.CONNECTING) private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(application) private val keySourcePositions = application.getString(R.string.pref_positions_source) private val LIVE_POS_PREF_MQTT : String private val LIVE_POS_PREF_GTFSRT :String val useMQTTPositionsLiveData: MutableLiveData init { sharedPrefs.registerOnSharedPreferenceChangeListener { shp, key -> if(key == keySourcePositions) { val newV = shp.getString(keySourcePositions, LIVE_POS_PREF_MQTT) useMQTTPositionsLiveData.postValue(newV.equals(LIVE_POS_PREF_MQTT)) Log.d(DEBUG_TI, "Changed position source to: $newV") } } LIVE_POS_PREF_MQTT = application.getString(R.string.positions_source_mqtt) LIVE_POS_PREF_GTFSRT = application.getString(R.string.positions_source_gtfsrt) useMQTTPositionsLiveData = MutableLiveData(isMQTTPositionsSelected()) } private fun isMQTTPositionsSelected(): Boolean{ val source = sharedPrefs.getString(keySourcePositions, LIVE_POS_PREF_MQTT) val useMQTT=source == LIVE_POS_PREF_MQTT Log.d(DEBUG_TI, "Init positions, source: $source, isMQTT: $useMQTT") return useMQTT } /** * Switch provider of live positions from MQTT to GTFSRT and viceversa */ fun switchPositionsSource(){ val usingMQTT = useMQTTPositionsLiveData.value!! //code that was in the MapLibreFragment useMQTTPositionsLiveData.value = !usingMQTT sharedPrefs.edit(commit = true) { putString( keySourcePositions, if (usingMQTT) LIVE_POS_PREF_GTFSRT else LIVE_POS_PREF_MQTT ) } Log.d(DEBUG_TI, "Switched positions source in ViewModel, now using MQTT: ${!usingMQTT}") serviceStatus.value = LivePositionsServiceStatus.CONNECTING } var isLastWorkResultGood = workManager .getWorkInfosForUniqueWorkLiveData(MatoTripsDownloadWorker.TAG_TRIPS).map { it -> if (it.isEmpty()) return@map false var res = true if(it[0].state == WorkInfo.State.FAILED){ val currDate = Date() res = false lastRequestedDownloadTrips.value?.let { trips-> for(tr in trips){ lastFailedTripsRequest[tr] = currDate } } } return@map res } /** * Responder to the MQTT Client */ private val matoPositionListener = object: MQTTMatoClient.Companion.MQTTMatoListener{ override fun onUpdateReceived(it: PositionsMap) { val mupds = ArrayList() if(lineListening==MQTTMatoClient.LINES_ALL){ for(sdic in it.values){ for(update in sdic.values){ mupds.add(update) } } } else{ //we're listening to one if (it.containsKey(lineListening.trim()) ){ for(up in it[lineListening]?.values!!){ mupds.add(up) } } } //avoid updating the positions too often (limit to 0.5 seconds) val time = System.currentTimeMillis() if(lastTimeMQTTUpdatedPositions == (0.toLong()) || (time-lastTimeMQTTUpdatedPositions)>500){ positionsToBeMatchedLiveData.postValue(mupds) lastTimeMQTTUpdatedPositions = time } //we have received an update, so set the status to OK serviceStatus.postValue(LivePositionsServiceStatus.OK) } override fun onStatusUpdate(status: LivePositionsServiceStatus) { serviceStatus.postValue(status) } } //find the trip IDs in the updates private val tripsIDsInUpdates = positionsToBeMatchedLiveData.map { it -> //Log.d(DEBUG_TI, "Updates map has keys ${upMap.keys}") it.map { pos -> "gtt:"+pos.tripID }.filter{ s-> !(s.contains("null") || s.trim() =="gtt:") } } // get the trip IDs in the DB private val gtfsTripsPatternsInDB = tripsIDsInUpdates.switchMap { //Log.i(DEBUG_TI, "tripsIds in updates: ${it.size}") gtfsRepo.gtfsDao.getTripPatternStops(it) } //trip IDs to query, which are not present in the DB //REMEMBER TO OBSERVE THIS IN THE MAP val tripsGtfsIDsToQuery: LiveData> = gtfsTripsPatternsInDB.map { tripswithPatterns -> val tripNames=tripswithPatterns.map { twp-> twp.trip.tripID } Log.i(DEBUG_TI, "Have ${tripswithPatterns.size} trips in the DB") if (tripsIDsInUpdates.value!=null) return@map tripsIDsInUpdates.value!!.filter { !( tripNames.contains(it) || it.contains("null") || it =="gtt:" )}.distinct() else { Log.e(DEBUG_TI,"Got results for gtfsTripsInDB but not tripsIDsInUpdates??") return@map ArrayList() } } /** * This livedata object contains the final updates with patterns present in the DB */ val updatesWithTripAndPatterns = gtfsTripsPatternsInDB.map { tripPatterns-> - //TODO: Change the mapping in the final updates, I don't know why the key is the tripID and not the vehicle ID - Log.i(DEBUG_TI, "Mapping trips and patterns") - val mdict = HashMap() + //Integrate trips and patterns + //Log.i(DEBUG_TI, "Mapping trips and patterns") + //val mdict = HashMap() + val upsByVeh = HashMap() //missing patterns val routesToDownload = HashSet() if(positionsToBeMatchedLiveData.value!=null) for(update in positionsToBeMatchedLiveData.value!!){ + if(!update.hasTripId()){ + //when there is no trip information + upsByVeh[update.vehicle] = Pair(update, null) + continue + } - val trID:String = update.tripID + val trID = update.tripID + val veh = update.vehicle var found = false for(trip in tripPatterns){ if (trip.pattern == null){ //pattern is null, which means we have to download // the pattern data from MaTO routesToDownload.add(trip.trip.routeID) } if (trip.trip.tripID == "gtt:$trID"){ found = true //insert directly - mdict[trID] = Pair(update,trip) + upsByVeh[veh] = Pair(update,trip) break } } if (!found){ //Log.d(DEBUG_TI, "Cannot find pattern ${tr}") //give the update anyway - mdict[trID] = Pair(update,null) + upsByVeh[veh] = Pair(update,null) } } //have to request download of missing Patterns if (routesToDownload.isNotEmpty()){ Log.d(DEBUG_TI, "Have ${routesToDownload.size} missing patterns from the DB: $routesToDownload") //downloadMissingPatterns (ArrayList(routesToDownload)) MatoPatternsDownloadWorker.downloadPatternsForRoutes(routesToDownload.toList(), getApplication()) } - return@map mdict + return@map upsByVeh } fun clearOldPositionsUpdates(){ //RETURN if the map is null val positionsOld = positionsToBeMatchedLiveData.value ?: return val currentTimeSecs = (System.currentTimeMillis() / 1000 ) val updatedList = ArrayList() for (up in positionsOld){ //If the time has passed, remove it if (currentTimeSecs - up.timestamp <= MAX_MINUTES_CLEAR_POSITIONS*60) //TODO decide time limit in minutes updatedList.add(up) } val diff = positionsOld.size - updatedList.size Log.d(DEBUG_TI, "Removed ${diff} positions marked as old") // Re-trigger all the LiveData chain positionsToBeMatchedLiveData.value = updatedList } fun clearAllPositions(){ positionsToBeMatchedLiveData.postValue(ArrayList()) Log.d(DEBUG_TI, "Cleared all positions in LivePositionsViewModel") } //OBSERVE THIS TO GET THE LOCATION UPDATES FILTERED val filteredLocationUpdates = MediatorLiveData>>() init { filteredLocationUpdates.addSource(updatesWithTripAndPatterns){ filteredLocationUpdates.postValue(filterUpdatesForGtfsLine(it, gtfsLineToFilterPos.value!!)) } filteredLocationUpdates.addSource(gtfsLineToFilterPos){ Log.d(DEBUG_TI, "line to filter change to: ${gtfsLineToFilterPos.value}") updatesWithTripAndPatterns.value?.let{ ups-> filteredLocationUpdates.postValue(filterUpdatesForGtfsLine(ups, it)) //Log.d(DEBUG_TI, "Set ${ups.size} updates as new value for filteredLocation") } } } private fun clearFilteredPositions(){ filteredLocationUpdates.postValue(Pair(HashMap(), ArrayList())) } fun setGtfsLineToFilterPos(line: String, pattern: MatoPattern?){ clearFilteredPositions() gtfsLineToFilterPos.value = Pair(line, pattern) } private fun filterUpdatesForGtfsLine(updates: FullPositionUpdatesMap, linePatt: Pair): Pair, List>{ val gtfsLineId = linePatt.first val pattern = linePatt.second - val updsForTripId = HashMap>() + val updsByVeh = HashMap>() val vehicleOnWrongDirection = mutableListOf() //supporting the eventual null case when there is no need to filter if (gtfsLineId == "ALL"){ //copy the dict - for ((tripId, pair) in updates.entries) { - updsForTripId[tripId] = pair + for ((vehicle, pair) in updates.entries) { + updsByVeh[vehicle] = pair } } else { val filtdLineID = GtfsUtils.stripGtfsPrefix(gtfsLineId) //filter buses with direction, show those only with the same direction val directionId = pattern?.directionId ?: -100 val numUpds = updates.entries.size Log.d( DEBUG_TI, "Got $numUpds updates, using MQTT: ${useMQTTPositionsLiveData.value}, pattern ${pattern?.name}" ) // cannot understand where this is used //val patternsDirections = HashMap() - for ((tripId, pair) in updates.entries) { + for ((veh, pair) in updates.entries) { //remove trips with wrong line val posUp = pair.first val vehicle = pair.first.vehicle - if (pair.first.routeID != filtdLineID) + if (posUp.routeID != filtdLineID) continue if (directionId != -100 && pair.second != null && pair.second?.pattern != null) { val dir = pair.second!!.pattern!!.directionId if (dir == directionId) { //add the trip - updsForTripId[tripId] = pair + updsByVeh[veh] = pair //Log.d(DEBUG_TI, "Add vehicle ${pair.first.vehicle}, route ${pair.first.routeID}") } else { vehicleOnWrongDirection.add(vehicle) } - //patternsDirections[tripId] = dir ?: -10 + //patternsDirections[veh] = dir ?: -10 } else { - updsForTripId[tripId] = pair - //Log.d(DEBUG_TAG, "No pattern for tripID: $tripId") - //patternsDirections[tripId] = -10 + updsByVeh[veh] = pair + //Log.d(DEBUG_TAG, "No pattern for tripID: $veh") + //patternsDirections[veh] = -10 } } } - Log.d(DEBUG_TI, "Filtered updates are ${updsForTripId.keys.size}") // Original updates directs: $patternsDirections\n + Log.d(DEBUG_TI, "Filtered updates are ${updsByVeh.keys.size}") // Original updates directs: $patternsDirections\n - return Pair(updsForTripId, vehicleOnWrongDirection) + return Pair(updsByVeh, vehicleOnWrongDirection) } fun requestMatoPosUpdates(line: String){ lineListening = line viewModelScope.launch { mqttClient.startAndSubscribe(line,matoPositionListener, getApplication()) //clear old positions (useful when we are coming back to the map after some time) mqttClient.clearOldPositions(MAX_MINUTES_CLEAR_POSITIONS) } //updatePositions(1000) } fun stopMatoUpdates(){ viewModelScope.launch { val tt = System.currentTimeMillis() mqttClient.stopMatoRequests(matoPositionListener) val time = System.currentTimeMillis() -tt Log.d(DEBUG_TI, "Took $time ms to unsubscribe") } } fun retriggerPositionUpdate(){ if(positionsToBeMatchedLiveData.value!=null){ positionsToBeMatchedLiveData.postValue(positionsToBeMatchedLiveData.value) } } //Gtfs Real time private val gtfsPositionsReqListener = object: GtfsRtPositionsRequest.Companion.RequestListener{ override fun onResponse(response: ArrayList?) { Log.i(DEBUG_TI,"Got response from the GTFS RT server") if (response == null){ serviceStatus.postValue(LivePositionsServiceStatus.ERROR_CONNECTION) } else response.let { it:ArrayList -> val ss: LivePositionsServiceStatus if (it.size == 0) { Log.w(DEBUG_TI,"No position updates from the GTFS RT server") ss = LivePositionsServiceStatus.NO_POSITIONS } else { //Log.i(DEBUG_TI, "Posting value to positionsLiveData") viewModelScope.launch { positionsToBeMatchedLiveData.postValue(it) } ss = LivePositionsServiceStatus.OK } serviceStatus.postValue(ss) } gtfsRtRequestRunning.postValue(false) } } /** * Listener for the errors in downloading positions from GTFS RT */ private val positionRequestErrorListener = GtfsRtPositionsRequest.Companion.ErrorListener { Log.e(DEBUG_TI, "Could not download the update", it) gtfsRtRequestRunning.postValue(false) if(it is GtfsRtPositionsRequest.RequestError){ val status = when(it.result) { Fetcher.Result.OK -> LivePositionsServiceStatus.OK Fetcher.Result.PARSER_ERROR -> LivePositionsServiceStatus.ERROR_PARSING_RESPONSE Fetcher.Result.SERVER_ERROR_404 -> LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE Fetcher.Result.SERVER_ERROR -> LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE Fetcher.Result.CONNECTION_ERROR -> LivePositionsServiceStatus.ERROR_CONNECTION else -> LivePositionsServiceStatus.ERROR_CONNECTION } serviceStatus.postValue(status) } else serviceStatus.postValue(LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE) } fun requestGTFSUpdates(){ if(gtfsRtRequestRunning.value == null || !gtfsRtRequestRunning.value!!) { val request = GtfsRtPositionsRequest(positionRequestErrorListener, gtfsPositionsReqListener) request.setRetryPolicy( DefaultRetryPolicy(1000,10,DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) ) netVolleyManager.requestQueue.add(request) Log.i(DEBUG_TI, "Requested GTFS realtime position updates") gtfsRtRequestRunning.value = true } } fun requestDelayedGTFSUpdates(timems: Long){ viewModelScope.launch { delay(timems) requestGTFSUpdates() } } override fun onCleared() { //stop the MQTT Service Log.d(DEBUG_TI, "Clearing the live positions view model, stopping the mqttClient") mqttClient.disconnect() super.onCleared() } //Request trips download fun downloadTripsFromMato(trips: List): Boolean{ if(trips.isEmpty()) return false var shouldContinue = false val currentDateTime = Date().time for (tr in trips){ if (!lastFailedTripsRequest.containsKey(tr)){ shouldContinue = true break } else{ //Log.i(DEBUG_TI, "Last time the trip has failed is ${lastFailedTripsRequest[tr]}") if ((lastFailedTripsRequest[tr]!!.time - currentDateTime) > MAX_TIME_RETRY){ shouldContinue =true break } } } if (shouldContinue) { //if one trip val workRequ =MatoTripsDownloadWorker.requestMatoTripsDownload(trips, getApplication(), "BusTO-MatoTripsDown") workRequ?.let { req -> Log.d(DEBUG_TI, "Enqueueing new work, saving work info") lastRequestedDownloadTrips.postValue(trips) //isLastWorkResultGood = } } else{ Log.w(DEBUG_TI, "Requested to fetch data for ${trips.size} trips but they all have failed before in the last $MAX_MINUTES_RETRY mins") } return shouldContinue } companion object{ private const val DEBUG_TI = "BusTO-LivePosViewModel" private const val MAX_MINUTES_RETRY = 3 private const val MAX_TIME_RETRY = MAX_MINUTES_RETRY*60*1000 //3 minutes (in milliseconds) public const val MAX_MINUTES_CLEAR_POSITIONS = 10 } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_round_downloading.xml b/app/src/main/res/drawable/ic_round_downloading.xml new file mode 100644 index 0000000..30a1590 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_downloading.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_warning_larger.xml b/app/src/main/res/drawable/ic_round_warning_larger.xml new file mode 100644 index 0000000..d7e9a57 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_warning_larger.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/include_map_bottom_sheet.xml b/app/src/main/res/layout/include_map_bottom_sheet.xml index c49b9f5..63ffb40 100644 --- a/app/src/main/res/layout/include_map_bottom_sheet.xml +++ b/app/src/main/res/layout/include_map_bottom_sheet.xml @@ -1,196 +1,237 @@ - + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c6ed47d..682c36d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,267 +1,270 @@ Stai utilizzando l\'ultimo ritrovato in materia di rispetto della tua privacy. Cerca Codice QR Scansiona codice QR alla fermata Si No Prossimo Precedente Necessaria app per leggere i codici QR Questa azione richiede un\'altra app per scansionare i codici QR, ma non è stata trovata sul dispositivo. Vuoi installare Binary Eye? Numero fermata Nome fermata Inserisci il numero della fermata Inserisci il nome della fermata Verifica l\'accesso ad Internet! Sembra che nessuna fermata abbia questo nome Nessun passaggio trovato alla fermata Ricerca arrivi da %1$s Errore di lettura del sito 5T/GTT (dannato sito!) Fermata: %1$s Fermata: Linea Linee Linee urbane Linee extraurbane Linee turistiche Direzione: Nessuna linea in questa categoria Nessuna linea corrisponde alla ricerca Filtra per nome Linea %1$s Linee: %1$s Linea %1$s, direzione: Fermata %1$s Scegli la fermata… Matricola %1$s Nessun passaggio Nessun QR code trovato, riprova Preferiti Aiuto Donazioni Informazioni sull\'app Più informazioni Vai alla wiki https://gitpull.it/w/librebusto/it/ Codice sorgente Licenza Incontra l\'autore Mostra linea Vedi direzione Fermata aggiunta ai preferiti Impossibile aggiungere ai preferiti (memoria piena o database corrotto?)! Preferiti Mappa Nessun preferito? Arghh!\nSchiaccia sulla stella di una fermata per aggiungerla a questa lista! Rimuovi Rinomina Rinomina fermata Reset Informazioni Tocca la stella per aggiungere la fermata ai preferiti\n\nCome leggere gli orari:\n 12:56* Orario in tempo reale\n 12:56 Orario programmato\n\nTrascina giù per aggiornare l\'orario. \nTocca a lungo su Fonte Orari per cambiare sorgente degli orari di arrivo OK! Benvenuto!\n \n

Grazie per aver scelto BusTO, un\'app open source e indipendente da GTT/5T, per spostarsi a Torino attraverso software libero!

\n

BusTO rispetta la tua privacy non raccogliendo nessun dato sull\'utilizzo, ed è leggera e senza pubblicità!

\n
\n

Qui puoi trovare più informazioni e link riguardo al progetto.

\n\t\t
\n

Schermata iniziale

\n

Se vuoi rivedere la schermata iniziale, usa il pulsante qui sotto:"]]> Notizie e aggiornamenti

Nel canale Telegram puoi trovare informazioni sugli ultimi aggiornamenti dell\'app

]]>
Ma come funziona?\n

Quest\'app ottiene i passaggi dei bus, le fermate e altre informazioni utili unendo dati forniti dal sito www.gtt.to.it, www.5t.torino.it, muoversiatorino.it \"per uso personale\" e altre fonti Open Data (aperto.comune.torino.it).

\n
\n\t\t

Ingredienti:
\n\t\t- Fabio Mazza attuale rockstar developer anziano.
\n\t\t- Andrea Ugo attuale rockstar developer in formazione.
\n\t\t- Silviu Chiriac designer del logo 2021.
\n\t\t- Marco M formidabile tester e cacciatore di bug.
\n\t\t- Ludovico Pavesi ex rockstar developer anziano asd.
\n\t\t- Valerio Bozzolan attuale manutentore.
\n\t\t- Marco Gagino apprezzato ex collaboratore, ideatore icona e grafica.
\n\t\t- JSoup libreria per \"web scaping\".
\n\t\t- Google icone e librerie di supporto e design.
\n\t\t- Altre icone da Bootstrap, Feather, Remix e Hero Icons
\n\t\t- Tutti i contributori e i beta tester!\n\t\t

\n

Se vuoi avere più informazioni o donare per supportare lo sviluppo dell\'app, usa i pulsanti qui sotto!

"]]>
Licenze\n\t\t

L\'app e il relativo codice sorgente sono distribuiti sotto la licenza GNU General Public License v3 (https://www.gnu.org/licenses/gpl-3.0.html).\n\t\tCiò significa che puoi usare, studiare, migliorare e ricondividere quest\'app con qualunque mezzo e per qualsiasi scopo: a patto di mantenere sempre questi diritti a tua volta e di dare credito a Valerio Bozzolan e agli altri autori del codice dell\'app.\n\t\t

\n\n\t\t
\n\t\t

Note

\n\t\t

Quest\'applicazione è rilasciata nella speranza che sia utile a tutti ma senza NESSUNA garanzia sul suo funzionamento attuale e/o futuro.

\n\t\t

Tutti i dati utilizzati dall\'app provengono direttamente da GTT o da simili agenzie pubbliche: se trovi che sono inesatti per qualche motivo, ti invitiamo a rivolgerti a loro.

\n\t\t

Buon utilizzo! :)

]]>
Nome troppo corto, digita più caratteri e riprova %1$s verso %2$s %s (destinazione sconosciuta) Errore interno inaspettato, impossibile estrarre dati dal sito GTT/5T Visualizza sulla mappa Non trovo un\'applicazione dove mostrarla Posizione della fermata non trovata Vicino a me Fermate vicine Ricerca della posizione Nessuna fermata nei dintorni Preferenze Aggiornamento del database… Aggiornamento del database Aggiornamento database forzato Tocca per aggiornare ora il database Numero minimo di fermate Il numero di fermate da ricercare non è valido Valore errato, inserisci un numero Impostazioni Distanza massima di ricerca (m) Funzionalità sperimentali Impostazioni Generali Fermate recenti Impostazioni generali Gestione del database Lancia aggiornamento manuale del database Consenti l\'accesso alla posizione per mostrarla sulla mappa Consenti l\'accesso alla posizione per mostrare le fermate vicine Abilitare la posizione sul dispositivo arriva alle alla fermata Mostra arrivi Mostra fermate Arrivi qui vicino Fermata rimossa dai preferiti Canale telegram Mostra introduzione La mia posizione Segui posizione Attiva o disattiva posizione Posizione attivata Posizione disattivata La posizione è disabilitata sul dispositivo Fonte orari: %1$s App GTT Sito GTT Sito 5T Torino Muoversi a Torino Sconosciuta Fonti orari di arrivo Scegli le fonti di orari da usare Cambiamento sorgente orari… Premi a lungo per cambiare la sorgente degli orari Nessun passaggio per le linee: Canale default delle notifiche Operazioni sul database Informazioni sul database (aggiornamento) BusTO - posizioni in tempo reale Posizioni in tempo reale Attività del servizio delle posizioni in tempo reale Servizio posizioni MaTO in tempo reale attivo Download dei trip dal server MaTO Chiesto troppe volte per il permesso %1$s Non si può usare questa funzionalità senza il permesso di archivio! di archivio Un bug ha fatto crashare l\'app! \nPremi \"OK\" per inviare il report agli sviluppatori via email, così potranno scovare e risolvere il tuo bug! \nIl report contiene piccole informazioni non sensibili sulla configurazione del tuo telefono e sullo stato dell\'app al momento del crash. L\'applicazione è crashata, e il crash report è stato messo negli allegati. Se vuoi, descrivi cosa stavi facendo prima che si interrompesse: Arrivi Mappa Preferiti Apri drawer Chiudi drawer Esperimenti Offrici un caffè Mappa Ricerca fermate Versione app Orari di arrivo Richiesto aggiornamento del database Download dati dal server MaTO Mostra direzioni in maiuscolo Non cambiare Tutto in maiuscolo Solo prima lettera maiuscola Mostra arrivi quando tocchi una fermata Abilita esperimenti Schermata da mostrare all\'avvio Tocca per cambiare Fonte posizioni in tempo reale di bus e tram MaTO (aggiornate più spesso, può avere errori) GTFS RT (aggiornato meno frequentemente, posizioni più controllate) Linea aggiunta ai preferiti Linea rimossa dai preferiti Preferite Tocca a lungo la fermata per le opzioni Stile della mappa Versatiles (vettoriale) OSM Legacy (raster, più leggera) Rimuovi i dati dei trip (libera spazio) Tutti i trip GTFS sono rimossi dal database Mostra introduzione open source per il trasporto pubblico di Torino. Stai usando un\'app indipendente, senza pubblicità e senza nessun tracciamento.]]> Se ti trovi a una fermata, puoi scansionare il codice QR presente sulla palina toccando l\'icona a sinistra della barra di ricerca.]]> preferiti toccando la stella a fianco del nome.]]> fermate più vicine a te direttamente nella schermata principale...]]> posizioni in tempo reale dei bus e tram (in blu)]]> Guarda nelle Impostazioni per personalizzare l\'app come preferisci, e su Informazioni per sapere di più sull\'app e il team di sviluppo.]]> Capito, chiudi introduzione Chiudi introduzione Abilita accesso alla posizione Accesso alla posizione abilitato Accesso alla posizione non consentito dall\'utente Abilita notifiche Notifiche abilitate Backup e ripristino Dati salvati Backup Ripristino Backup completato Salva o ripristina i dati Salva backup Importa i dati dal backup Backup importato Seleziona almeno un elemento da importare! Importa preferiti dal backup Importa preferenze dal backup Nessuna app disponibile per mostrare la fermata! Destinazione sconosciuta Direzione già selezionata Sei troppo lontano, posizione nascosta Caricamento destinazione… Ciao fragment vuoto Il servizio delle posizioni funziona normalmente Nessuna posizione ricevuta Errore nella connessione al server Errore nel parsing della risposta Errore: risposta dal server inaspettata In connessione... Fonte posizioni in tempo reale: Cambia fonte Rimuovi posizioni sulla mappa quando si cambia fonte delle posizioni in tempo reale Aggiornato: %1$s Nessun avviso nella tua lingua, mostrati in %1$s Italiano Inglese Avvisi per la linea %1$s: Controllo degli avvisi disponibili in corso Servizio GPS non trovato sul dispositivo! Download dati avvisi in tempo reale Permesso mancante Per usare questa funzionalità, l\'app necessita dell\'accesso alla posizione, che ora può essere dato solo dalle impostazioni di sistema. Apri le impostazioni Premi ancora \"indietro\" per uscire dall\'app Chiaro Scuro Segui il sistema Imposta tema scuro o chiaro + + Nessuna informazione trasmessa sulla direzione o sul viaggio del veicolo. + Download in corso delle informazioni sul viaggio
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3c9bf34..ad71962 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,102 +1,103 @@ #ff9800 #E77D13 #F57C00 #f57200 #e66b00 #cc6600 #994d00 #b35900 #2196F3 #0b6fc1 #2a65e8 #2060dd #8A4247 #FFF3E0 #fff5e5 #e08700 #d16900 #2378e8 #0079f5 #2a968b #0067ff #2F59CC #CC5E43 #548017 #228b22 #0ABA34 #009688 #00a38d #009480 #00a887 #4DB6AC #80cbc4 #008175 #F5F5F5 #dddddd #f8f8f8 #bababa #acacac #757575 #444 #353535 #303030 #f2f2f2 #DE0908 #b30000 #dd441f #b30d0d #f15000 #f2621a #f47333 #2060DD #FFFFFF #000000 #1c1c1c #3f3f3f @color/blue_mid_2 @color/red_dark @color/blue_extra #3089e8 #FF039BE5 #FF01579B #FF40C4FF #FF00B0FF #66000000 #555 #cccccc @color/grey_500 #00000000 @color/orange_700 @color/grey_200 @color/grey_050 @color/grey_200 @color/orange_500 @color/blue_extraurbano @color/metro_red @color/orange_icons_10light @color/grey_400 @color/orange_750_l45 @color/grey_700 @color/blue_700 @color/light_blue_900 + #fbb504 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20acdfe..3d627d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,420 +1,422 @@ BusTO Libre BusTO BusTO dev BusTO git You\'re using the latest in technology when it comes to respecting your privacy. Search Scan QR Code Yes No Next Previous Barcode scanner app is needed This functionality requires another app to scan the QR codes, which has not been found on the device. We suggest using Binary Eye, which is open source software, would you like to install it now? Bus stop number Bus stop name Insert bus stop number Insert bus stop name %1$s towards %2$s %s (unknown destination) Verify your Internet connection! Seems that no bus stop has this name No arrivals found for this stop Error parsing the 5T/GTT website (damn site!) Name too short, type more characters and retry Arrivals at: %1$s Arrivals at: Choose the bus stop… Line Lines Urban lines Extra urban lines Tourist lines No lines found in this category No lines match the searched name Destination: Lines: %1$s Line %1$s Line %1$s towards: Stop %1$s Vehicle %1$s No timetable found No QR code found, retry Scan QR code at the bus stop Unexpected internal error, cannot extract data from GTT/5T website Help About the app More about Open the wiki https://gitpull.it/w/librebusto/en/ Source code Licence11 Meet the author Bus stop is now in your favorites Bus stop removed from your favorites Added line to favorites Remove line from favorites Favorites Favorites Favorites Map No favorites? Arghh! Press on a bus stop star to populate this list! Delete Rename Rename the bus stop Reset About the app Tap the star to add the bus stop to the favourites\n\nHow to read timelines:\n   12:56* Real-time arrivals\n   12:56   Scheduled arrivals\n\nPull down to refresh the timetable \n Long press on Arrivals source to change the source of the arrival times GOT IT! Arrival times No arrivals found for lines: Welcome!

Thanks for using BusTO, an open source and independent app useful to move around Torino using a Free/Libre software.


Why use this app?

- You\'ll never be tracked
- You\'ll never see boring ads
- We\'ll always respect your privacy
- Moreover, it\'s lightweight!


Introductory tutorial

If you want to see the introduction again, use the button below:

]]>
News and Updates

On the Telegram channel, you can find information about the latest app updates

]]>
How does it work?

This app is able to do all the amazing things it does by pulling data from www.gtt.to.it, www.5t.torino.it or muoversiatorino.it "for personal use", along with open data from the AperTO (aperto.comune.torino.it) website.


The work of several people is behind this app, in particular:
- Fabio Mazza, current senior rockstar developer.
- Andrea Ugo, current junior rockstar developer.
- Silviu Chiriac, designer of the 2021 logo.
- Marco M, rockstar tester and bug hunter.
- Ludovico Pavesi, previous senior rockstar developer (asd).
- Valerio Bozzolan, maintainer and infrastructure (sponsor).
- Marco Gagino, contributor and first icon creator.
- JSoup web scraper library.
- makovkastar floating buttons.
- Google for icons and support and design libraries.
- Other icons from Bootstrap, Feather, Remix and Hero Icons.
- All the contributors, and the beta testers, too!


If you want more detailed information, or want to donate money to support the development, use the buttons below!

]]>
Licenses

The app and the related source code are released by Valerio Bozzolan and the other authors under the terms of the GNU General Public License v3+). So everyone is allowed to use, to study, to improve and to share this app by any kind of means and for any purpose: under the conditions of maintaining this rights and of attributing the original work to Valerio Bozzolan.


Notes

This app has been developed with the hope to be useful to everyone, but comes without ANY warranty of any kind.

The data used by the app comes directly from GTT and other public agencies: if you find any errors, please take it up to them, not to us.

This translation is kindly provided by Riccardo Caniato, Marco Gagino and Fabio Mazza.

Now you can hack public transport, too! :)

]]>
Cannot add to favorites (storage full or corrupted database?)! View on a map Show line details Show full direction Cannot find any application to show it in Cannot find the position of the stop ListFragment - BusTO it.reyboz.bustorino.preferences db_is_updating Near me Nearby stops Nearby arrivals App version The number of stops to show in the recent stops is invalid Invalid value, put a valid number Finding location No stops nearby Loading arrival times No stops nearby to search arrival times for Minimum number of stops Preferences Settings Settings General Experimental features Maximum distance (meters) Recent stops General settings Database management Launch manual database update Allow access to location to show it on the map Allow access to location to show stops nearby Please enable location on the device No GPS receiver found on the device! Database update in progress… Updating the database Force database update Touch to update the app database now is arriving at at the stop %1$s - %2$s Show arrivals Show stops Join Telegram channel Show introduction Center on my location Follow me Enable or disable location Location enabled Location disabled Location is disabled on device Arrivals source: %1$s Loading arrivals from %1$s GTT App GTT Website 5T Torino website Muoversi a Torino Undetermined Changing arrival times source… Long press to change the source of arrivals @string/source_mato @string/fivetapifetcher @string/gttjsonfetcher @string/fivetscraper Sources of arrival times Select which sources of arrival times to use Default Default channel for notifications Database operations Updates of the app database BusTO - live position service Live positions Showing activity related to the live positions service MaTO live bus positions service is running Downloading trips from MaTO server Asked for %1$s permission too many times Cannot use the map with the storage permission! storage The application has crashed because you encountered a bug. \nIf you want, you can help the developers by sending the crash report via email. \nNote that no sensitive data is contained in the report, just small bits of info on your phone and app configuration/state. The application crashed and the crash report is in the attachments. Please describe what you were doing before the crash: \n Arrivals Home Map Favorites Open navigation drawer Close navigation drawer Experiments Buy us a coffee Map Search by stop Filter by name Launching database update Downloading data from MaTO server Downloading realtime alerts data Capitalize directions @string/directions_capitalize_no_change @string/directions_capitalize_everything @string/directions_capitalize_first_letter Do not change arrivals directions Capitalize everything Capitalize only first letter KEEP CAPITALIZE_ALL CAPITALIZE_FIRST Section to show on startup Touch to change it Show arrivals touching on stop Enable experiments Long press the stop for options @string/nav_home_text @string/near_me_title @string/nav_favorites_text @string/nav_map_text @string/lines Light Dark Change it with the system Set dark or light theme Source of real time positions for buses and trams MaTO (updated more frequently, might have errors) GTFS RT (less frequently updated, more accurate) @string/positions_source_mato_descr @string/positions_source_gtfsrt_descr "You are too far, not showing your position Style of the map Versatiles (vector) OSM legacy (raster, lighter) @string/map_style_versatiles @string/map_style_legacy_raster Remove trips data (free up space) All GTFS trips have been removed from the database Show tutorial open source app for Turin public transport. This is an independent app, with no ads and no tracking whatsoever.]]> favorites by touching the star next to its name]]> blue)]]> Settings to customize the app behaviour, and in the About the app section if you want to know more about the app and the developers.]]> Notifications permission to show the information about background processing. Press the button below to grant it]]> Grant location permission Location permission granted Location permission has not been granted Missing permission To use this functionality, the application needs access to the location, which now can only be granted in the system settings. Open settings OK, close the tutorial Close the tutorial Enable notifications Notifications enabled Backup and restore Backup Restore Backup or restore data Data saved Backup completed Backup to file Import data from backup Backup has been imported Check at least one item to import! Import favorites from backup Import preferences from backup Hello blank fragment No map app present to show the stop! Direction is already shown Loading destination… Destination unknown Service for positions working normally No positions received Error connecting to the server Error parsing the server response Error: network response is not as expected Connecting... MaTO GTFS RT Live positions source: Switch source Clear bus positions when switching live positions source Updated: %1$s Alerts for line %1$s: Alerts for stop %1$s: No alerts in your language, showing in %1$s Italian English Checking new alerts now Press back again to close the app Donate + No information on the direction or trip of the vehicle was transmitted. + Downloading trip information…