diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt @@ -1,3 +1,20 @@ +/* + BusTO - Fragments components + Copyright (C) 2026 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.os.Bundle diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt @@ -1,3 +1,20 @@ +/* + BusTO - Fragments components + Copyright (C) 2026 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.os.Bundle diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/BackupImportFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/BackupImportFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/BackupImportFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/BackupImportFragment.kt @@ -1,3 +1,20 @@ +/* + BusTO - Fragments components + Copyright (C) 2024 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.app.Activity diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,5 +1,23 @@ +/* + 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 @@ -19,6 +37,9 @@ import android.widget.ImageView 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 @@ -28,6 +49,7 @@ 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 @@ -38,7 +60,10 @@ 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 @@ -48,7 +73,10 @@ import org.maplibre.android.camera.CameraPosition import org.maplibre.android.geometry.LatLng import org.maplibre.android.location.LocationComponent -import org.maplibre.android.location.LocationComponentOptions +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 @@ -67,6 +95,7 @@ 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 @@ -87,6 +116,11 @@ protected lateinit var sharedPreferences: SharedPreferences protected lateinit var bottomSheetBehavior: BottomSheetBehavior + protected var locationEngine: MapLibreLocationEngine? = null + protected lateinit var locationProvider: FusedNativeLocationProvider + + protected var shownToastNoPosition = false + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener(){ pref, key -> /*when(key){ @@ -100,6 +134,28 @@ reloadMap() } } + + 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: RelativeLayout? = null protected lateinit var stopTitleTextView: TextView @@ -134,7 +190,39 @@ //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 + locationEngine?.removeLocationUpdates(this) + } + } + + override fun onFailure(exception: Exception) { + Log.e(DEBUG_TAG, "Error in getting position: ${exception.message}") + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -181,6 +269,8 @@ } } + + @Deprecated("Deprecated in Java") override fun onLowMemory() { mapView.onLowMemory() @@ -377,24 +467,47 @@ } - /** - * Initialize the map location, but do not enable the component - */ + + abstract fun onMapLocationComponentInitialized() + @SuppressLint("MissingPermission") - protected 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 + protected fun setLocationComponentEnabled(enabled: Boolean): Boolean{ + var changed = false + map?.apply { + if(locationComponent.isLocationComponentEnabled !=enabled) + locationComponent.isLocationComponentEnabled= enabled + changed = true} + + return changed + } - lastLocation?.let { - if (it.accuracy < 200) - locationComponent.forceLocationUpdate(it) + @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 + + locationProvider = FusedNativeLocationProvider(context) + locationEngine = MapLibreLocationEngine(locationProvider) + val options = LocationComponentActivationOptions.builder(context, style) + .useDefaultLocationEngine(false) + .locationEngine(locationEngine) + .build() + locationComponent.activateLocationComponent(options) + //locationComponent.cameraMode = CameraMode.TRACKING + //locationComponent.renderMode = RenderMode.COMPASS + locationInitialized = true + if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Requesting location updates") + locationEngine!!.requestLocationUpdates(LocationEngineRequest.Builder(500).setDisplacement(20.0f).build(), + mapLibreLocationCallback, null) + // signal to show user location icon as active + mapStateViewModel.locationActive.value = true + setLocationComponentEnabled(true) + onMapLocationComponentInitialized() } } @@ -405,7 +518,6 @@ * Unified version that works with both fragments * * @param incomingData Map of updates with optional trip and pattern information - * @param checkCoordinateValidity If true, validates that coordinates are positive (default: false) * @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 */ @@ -587,7 +699,7 @@ // Schedule delayed update if(lifecycleOwnerLiveData.value != null) viewLifecycleOwner.lifecycleScope.launch { - delay(200) + delay(200.milliseconds) updatePositionsIcons(forced) } return @@ -874,6 +986,76 @@ style.addLayerAbove(selectedBusLayer, BUSES_LAYER_ID) } + /** + * 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) { + setMapLocationEnabled(false) + onMapLocationEnabled(false) + } + else if(deviceHasGpsProvider()) { + if(Permissions.bothLocationPermissionsGranted(context)){ + setMapLocationEnabled(true) + onMapLocationEnabled(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() + } + } + + } + + @SuppressLint("MissingPermission") + protected fun setMapLocationEnabled(enabled: Boolean){ + map?.locationComponent?.isLocationComponentEnabled = enabled + //map?.cameraPosition = + mapStateViewModel.locationActive.value = enabled + } + protected fun checkInitMapLocation(mapReady: MapLibreMap,style: Style, context: Context) { + //enable location + val hasGps = deviceHasGpsProvider() + 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) + } + //setLocationIconEnabled(false) + //setFollowingUser(false) + if(!req) { + setMapLocationEnabled(false) + onMapLocationEnabled(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 @@ -920,6 +1102,13 @@ .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" @@ -951,5 +1140,11 @@ 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/fragments/IntroFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/IntroFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/IntroFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/IntroFragment.kt @@ -1,3 +1,20 @@ +/* + 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.content.Context @@ -35,7 +52,7 @@ private val locationRequestResLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()){ res -> //onActivityResult(res: map) - if(res.get(Permissions.LOCATION_PERMISSIONS[0])==true || res.get(Permissions.LOCATION_PERMISSIONS[1])==true) + if(res[Permissions.LOCATION_PERMISSIONS[0]] ==true || res[Permissions.LOCATION_PERMISSIONS[1]] ==true) setInteractButtonState(ButtonState.LOCATION,false) } private val notificationsReqLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { @@ -94,7 +111,8 @@ setInteractButtonState(ButtonState.LOCATION, !permGranted) interactButton.setOnClickListener { //ask location permission - locationRequestResLauncher.launch(Permissions.LOCATION_PERMISSIONS) + Permissions.getInstance(requireContext()).checkRequestLocationPermissions(requireActivity(), locationRequestResLauncher) + //locationRequestResLauncher.launch(Permissions.LOCATION_PERMISSIONS) } interactButton.visibility = View.VISIBLE } diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -18,19 +18,17 @@ package it.reyboz.bustorino.fragments -import android.Manifest import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences +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.widget.* -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat @@ -52,7 +50,6 @@ import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops import it.reyboz.bustorino.map.* -import it.reyboz.bustorino.middleware.LocationUtils import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.MapStateViewModel @@ -75,7 +72,6 @@ import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.LineString import org.maplibre.geojson.Point -import java.util.concurrent.atomic.AtomicBoolean class LinesDetailFragment() : GeneralMapLibreFragment() { @@ -89,7 +85,7 @@ private var shouldMapLocationBeReactivated = true private var toRunWhenMapReady : Runnable? = null - private var mapInitialized = AtomicBoolean(false) + //private var mapInitialized = AtomicBoolean(false) //private var patternsSpinnerState: Parcelable? = null @@ -183,20 +179,6 @@ 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 @@ -207,8 +189,6 @@ private var usingMQTTPositions = true private var restoredCameraInMap = false - - //position of live markers private val tripMarkersAnimators = HashMap() @@ -232,7 +212,7 @@ //isBottomSheetShowing = false //stopsLayerStarted = false lastStopsSizeShown = 0 - mapInitialized.set(false) + mapInitialized = false val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) //lineID = requireArguments().getString(LINEID_KEY, "") @@ -294,10 +274,8 @@ } } locationIcon?.let {view -> - if(!LocationUtils.isLocationEnabled(requireContext()) || !Permissions.anyLocationPermissionsGranted(requireContext())) - setLocationIconEnabled(false) //set click Listener - view.setOnClickListener(this::onPositionIconButtonClick) + view.setOnClickListener(this::switchUserLocationStatus) } busPositionsIconButton.setOnClickListener { LivePositionsDialogFragment().show(parentFragmentManager, "LivePositionsDialog") @@ -373,6 +351,9 @@ descripTextView.text = route.longName descripTextView.visibility = View.VISIBLE } + mapStateViewModel.locationActive.observe(viewLifecycleOwner) { + setLocationIconEnabled(it) + } // enable info button if there are alerts on the line alertsViewModel.setGtfsLineFilter(lineID) alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner){ list -> @@ -458,7 +439,7 @@ hideStopOrBusBottomSheet() if(locationComponent.isLocationComponentEnabled){ - locationComponent.isLocationComponentEnabled = false + setLocationComponentEnabled(false) shouldMapLocationBeReactivated = true } else shouldMapLocationBeReactivated = false @@ -481,60 +462,52 @@ switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_list_30)) - if(shouldMapLocationBeReactivated && Permissions.bothLocationPermissionsGranted(requireContext())){ - locationComponent.isLocationComponentEnabled = true + if(shouldMapLocationBeReactivated){ + setLocationComponentEnabled(Permissions.bothLocationPermissionsGranted(requireContext())) } } - private fun setLocationIconEnabled(setTrue: Boolean){ - if(setTrue) + override fun setLocationIconEnabled(enabled: Boolean){ + if(enabled) { locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) - else - locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) + } + 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()) + override fun onMapLocationEnabled(active: Boolean) { + //extra thing: show the toast + showToastLocation(active) + } - if (permissionOk) { - Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions") - locationComponent.isLocationComponentEnabled = true - //locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING + override fun onMapLocationComponentInitialized() { + //enable the position after the first fix + //onMapLocationEnabled(true) + } - 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() + @SuppressLint("MissingPermission") + override fun onFirstReceivedLocation(location: Location) { + if(mapInitialized){ + val center = map!!.cameraPosition.target + val newPos = LatLng(location.latitude, location.longitude) + Log.d(DEBUG_TAG, "Center of the map : $center") + val newStatus = if(center==null || newPos.distanceTo(center) > 20*1000){ + Log.d(DEBUG_TAG, "Distance from center of map to location: "+center?.distanceTo(newPos)) + if(!shownToastNoPosition) context?.let{ c-> + Toast.makeText(c, R.string.too_far_not_showing_location, Toast.LENGTH_LONG).show() + shownToastNoPosition = true } - 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 + false + } else{ + true } - } - - } - - /** - * Switch position icon from activ - */ - private fun onPositionIconButtonClick(view: View){ - if(locationComponent.isLocationComponentEnabled) setMapUserLocationEnabled(false, false, true) - else{ - setMapUserLocationEnabled(true, false, true) + if(!newStatus) setLocationComponentEnabled(newStatus) + mapStateViewModel.locationActive.value = newStatus } } @@ -558,8 +531,7 @@ mapStyle = style //setupLayers(style) - // Start observing data - initMapUserLocation(style, mapReady, requireContext()) + //checkInitMapLocation(mapReady, style,requireContext()) //if(!stopsLayerStarted) initPolylineStopsLayers(style, null) @@ -569,7 +541,7 @@ initSymbolManager(mapReady, style) toRunWhenMapReady?.run() toRunWhenMapReady = null - mapInitialized.set(true) + mapInitialized = true if(patternShown!=null){ viewModel.stopsForPatternLiveData.value?.let { @@ -646,7 +618,8 @@ savedCameraPosition = null - if(shouldMapLocationBeReactivated) setMapUserLocationEnabled(true, false, false) + if(shouldMapLocationBeReactivated) + mapReady.style?.let{ checkInitMapLocation(mapReady,it, context)} } override fun showOpenStopWithSymbolLayer(): Boolean { @@ -922,7 +895,7 @@ } private fun displayPatternWithStopsOnMap(patternWs: MatoPatternWithStops, stopsToSort: List, zoomToPattern: Boolean){ - if(!mapInitialized.get()){ + if(!mapInitialized){ //set the runnable and do nothing else Log.d(DEBUG_TAG, "Delaying pattern display to when map is Ready: ${patternWs.pattern.code}") toRunWhenMapReady = Runnable { @@ -1104,7 +1077,10 @@ override fun onStop() { super.onStop() mapView.onStop() - shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled + if(locationInitialized) + shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled + else + shouldMapLocationBeReactivated = false } override fun onDestroyView() { @@ -1137,7 +1113,8 @@ mapStyle.removeSource(BUSES_SOURCE_ID) - map?.locationComponent?.isLocationComponentEnabled = false + //map?.locationComponent?.isLocationComponentEnabled = false + setLocationComponentEnabled(false) } override fun getBaseViewForSnackBar(): View? { diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/LivePositionsDialogFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LivePositionsDialogFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/LivePositionsDialogFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LivePositionsDialogFragment.kt @@ -1,3 +1,20 @@ +/* + 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 it.reyboz.bustorino.R diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java --- a/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java @@ -1,4 +1,20 @@ - +/* + BusTO - Fragments components + Copyright (C) 2021 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; diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,12 +1,26 @@ +/* + 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.annotation.SuppressLint import android.content.Context -import android.content.res.ColorStateList import android.location.Location -import android.location.LocationListener import android.location.LocationManager import android.os.Bundle import android.util.Log @@ -16,18 +30,13 @@ import android.widget.ImageButton import android.widget.RelativeLayout import android.widget.Toast -import it.reyboz.bustorino.backend.FiveTNormalizer -import it.reyboz.bustorino.backend.gtfs.GtfsUtils -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import androidx.room.concurrent.AtomicBoolean import com.google.android.material.bottomsheet.BottomSheetBehavior +import it.reyboz.bustorino.BuildConfig import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate @@ -35,24 +44,22 @@ import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.MapLibreStyles -import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.StopsMapViewModel 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.engine.LocationEngineCallback +import org.maplibre.android.location.engine.LocationEngineRequest +import org.maplibre.android.location.engine.LocationEngineResult import org.maplibre.android.location.modes.CameraMode +import org.maplibre.android.location.modes.RenderMode import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection - -// 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 @@ -68,7 +75,6 @@ private var isUserMovingCamera = 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 in GeneralMapLibreFragment @@ -76,73 +82,13 @@ // Location stuff private lateinit var locationManager: LocationManager - private lateinit var showUserPositionButton: ImageButton + private lateinit var userLocationButton: 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 var restoredMapCamera = AtomicBoolean() - private var permissionsGranted = false - - //TODO: Rewrite this mess using LocationEngineProvider in MapLibre - 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 - permissionsGranted = true - if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) - return@ActivityResultCallback - 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") - - //FIRST CASE: I have no GPS - if( !locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) ){ - setMapLocationEnabled(false, false,false) - - } - else 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 @@ -200,8 +146,8 @@ arrivalsCard = bottomSheet.findViewById(R.id.arrivalsCardButton) directionsCard = bottomSheet.findViewById(R.id.directionsCardButton) - showUserPositionButton = rootView.findViewById(R.id.locationEnableIcon) - showUserPositionButton.setOnClickListener(this::switchUserLocationStatus) + userLocationButton = rootView.findViewById(R.id.locationEnableIcon) + userLocationButton.setOnClickListener(this::switchUserLocationStatus) followUserButton = rootView.findViewById(R.id.followUserImageButton) centerUserButton = rootView.findViewById(R.id.centerMapImageButton) busPositionsIconButton = rootView.findViewById(R.id.busPositionsImageButton) @@ -230,17 +176,16 @@ } 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 + if(context!=null && locationInitialized && locationComponent.isLocationComponentEnabled){ - setFollowingUser(!followingUserLocation) + // CameraMode.TRACKING makes the camera move and jump to the location + setFollowUserLocation(!followingUserLocation) } } - locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager + //locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager + /* if (Permissions.bothLocationPermissionsGranted(requireContext()) && deviceHasGpsProvider()) { + requestInitialUserLocation() } else{ if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { @@ -251,6 +196,8 @@ // PERMISSIONS REQUESTED AFTER MAP SETUP } + */ + // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { @@ -287,6 +234,8 @@ usingMQTTPositions = useMQTT } + mapStateViewModel.locationActive.observe(viewLifecycleOwner){ setLocationIconEnabled(it)} + mapStateViewModel.followingUserPosition.observe(viewLifecycleOwner){ updateFollowingIcon(it)} Log.d(DEBUG_TAG, "Fragment View Created!") @@ -310,7 +259,6 @@ //setupLayers(style) addImagesStyle(style) - initMapUserLocation(style, mapReady, requireContext()) //init stop layer with this val stopsInCache = stopsViewModel.getAllStopsLoaded() if(stopsInCache.isEmpty()) @@ -321,11 +269,12 @@ // Start observing data now that everything is set up observeStops() + + checkInitMapLocation(mapReady,style, context) } 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)){ @@ -342,21 +291,20 @@ mapReady.addOnCameraMoveStartedListener { v-> if(v== MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE){ //the user is moving the map - isUserMovingCamera = true + //isUserMovingCamera = true + updateFollowingIcon(false) } } mapReady.addOnMapClickListener { point -> onMapClickReact(point) } - - mapInitCompleted = true // we start requesting the bus positions now observeBusPositionUpdates() //Restoring data - if (initialStopToShow!=null){ + if (initialStopToShow!=null && initialStopToShow?.hasCoords() == true){ val s = initialStopToShow!! if(s.hasCoords()){ mapReady.cameraPosition = CameraPosition.Builder().target( @@ -381,15 +329,27 @@ } if(!boundsRestored){ - mapReady.cameraPosition = CameraPosition.Builder().target( - LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) - ).zoom(DEFAULT_ZOOM).build() + // we have not restored the bounds, open normally in target location + // TODO: check that the map is reopened in the same location + val lastLoc = mapStateViewModel.locationToShow + val defaultLoc = LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) + val proposedLoc = lastLoc?.let{ LatLng(lastLoc.latitude, lastLoc.longitude)} + val targetLoc = if(proposedLoc == null || proposedLoc.distanceTo(defaultLoc) > MAX_DIST_KM*1000) + defaultLoc + else proposedLoc + + + mapReady.cameraPosition = CameraPosition.Builder().target(targetLoc).zoom(DEFAULT_ZOOM).build() } restoredMapCamera.set(boundsRestored) + + } + mapInitialized = true + + //pendingLocationActivation = true + //positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) - pendingLocationActivation = true - positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } private fun onMapClickReact(point: LatLng): Boolean{ @@ -527,9 +487,10 @@ } */ //save last location - map?.locationComponent?.lastKnownLocation?.let{ - stopsViewModel.lastUserLocation = it - } + if (locationInitialized) + map?.locationComponent?.lastKnownLocation?.let{ + stopsViewModel.lastUserLocation = it + } } @@ -542,7 +503,6 @@ mapStyle.removeSource(BUSES_SOURCE_ID) - //map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return mapView @@ -627,9 +587,9 @@ livePositionsViewModel.updatesWithTripAndPatterns.observe(viewLifecycleOwner) { data: HashMap> -> Log.d( DEBUG_TAG, - "Have " + data.size + " trip updates, has Map start finished: " + mapInitCompleted + "Have " + data.size + " trip updates, has Map start finished: $mapInitialized" ) - if (mapInitCompleted) updateBusPositionsInMap(data, hasVehicleTracking = true) { veh -> + if (mapInitialized) updateBusPositionsInMap(data, hasVehicleTracking = true) { veh -> showVehicleTripInBottomSheet(veh) } if (!isDetached && !livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.requestDelayedGTFSUpdates( @@ -640,86 +600,87 @@ // ------ 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) + override fun onMapLocationComponentInitialized() { + //locationComponent.cameraMode = CameraMode.TRACKING + + locationComponent.renderMode = RenderMode.COMPASS + locationComponent.locationEngine?.apply{ + // this is only called once + getLastLocation(object : LocationEngineCallback { + override fun onSuccess(res: LocationEngineResult?) { + Log.d(DEBUG_TAG, "Got the last location, ${res?.lastLocation}") + res?.lastLocation?.let { loc -> + if(mapInitialized) + map?.cameraPosition = CameraPosition.Builder().target(LatLng(loc.latitude, loc.longitude)).build() + else + mapStateViewModel.locationToShow = loc } - } 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) + override fun onFailure(p0: java.lang.Exception) { + Log.e(DEBUG_TAG, "Failed to get the last location", p0) + } + }) + } } + override fun onMapLocationEnabled(active: Boolean) { + //Extra stuff to do + setFollowUserLocation(active) + } - /** - * 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 (!restoredMapCamera.get()) { - locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING - setFollowingUser(true) + override fun onFirstReceivedLocation(location: Location) { + + val it = location + if(locationInitialized && !receivedFirstLocation) { + //only zoom if the user position is close enough to the center + val newPoint = LatLng(it.latitude, it.longitude) + if(newPoint.distanceTo(LatLng( + MapLibreFragment.DEFAULT_CENTER_LAT, + MapLibreFragment.DEFAULT_CENTER_LON + )) + > MAX_DIST_KM * 1000){ + //show Toast + if(!shownToastNoPosition) context?.let{ c-> + Toast.makeText(c, R.string.too_far_not_showing_location, Toast.LENGTH_LONG).show() + shownToastNoPosition = true } - setLocationIconEnabled(true) - if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() - pendingLocationActivation =false - //locationComponent.locationEngine.requestLocationUpdates() + setLocationComponentEnabled(false) + //Update UI Status + mapStateViewModel.locationActive.value = false + mapStateViewModel.followingUserPosition.value = false } 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() + map?.apply { + animateCamera( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder().target(LatLng(location.latitude, location.longitude)).build() + ), + 1000 + ) + setLocationComponentEnabled(true) + locationComponent.cameraMode = CameraMode.TRACKING + mapStateViewModel.locationActive.value = true } - Log.d(DEBUG_TAG, "Requesting permission to show user location") - 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 + setFollowUserLocation(true) } } - + else{ + //check for this is when the map is used + mapStateViewModel.locationToShow = location + } } - - - private fun setLocationIconEnabled(enabled: Boolean){ + override fun setLocationIconEnabled(enabled: Boolean){ if (enabled) - showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) + userLocationButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else - showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) + userLocationButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } @@ -730,30 +691,18 @@ followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.walk_circle_inactive)) } - private fun setFollowingUser(following: Boolean){ - updateFollowingIcon(following) - followingUserLocation = following - if(following) - ignoreCameraMovementForFollowing = true - } - /** - * Method used for enabling / disabling the location + * This sets both the status on the component if it has been activated and the icon in the Fragment */ - private fun switchUserLocationStatus(view: View?){ - if(pendingLocationActivation || locationComponent.isLocationComponentEnabled) - setMapLocationEnabled(false, false, true) - else{ - if(locationManager.allProviders.contains(LocationManager.GPS_PROVIDER)) { - pendingLocationActivation = true - Log.d(DEBUG_TAG, "Request enable location") - setMapLocationEnabled(true, false, true) - } else{ - Log.w(DEBUG_TAG, "Cannot find location, no GPS") - } - + private fun setFollowUserLocation(enabled: Boolean){ + if(locationInitialized) { + if (enabled) + locationComponent.cameraMode = CameraMode.TRACKING + else locationComponent.cameraMode = CameraMode.NONE } + //update the icon by updating the livedata + mapStateViewModel.followingUserPosition.value = enabled } @@ -770,7 +719,6 @@ 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" diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java --- a/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java @@ -2,7 +2,11 @@ import android.Manifest; import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; import android.content.SharedPreferences; +import android.net.Uri; +import android.provider.Settings; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; @@ -11,10 +15,12 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import com.google.android.material.snackbar.Snackbar; import it.reyboz.bustorino.BuildConfig; +import it.reyboz.bustorino.R; import java.util.Map; @@ -98,6 +104,7 @@ }); } + public interface LocationRequestListener{ void onPermissionResult(boolean isCoarseGranted, boolean isFineGranted); } diff --git a/app/src/main/java/it/reyboz/bustorino/map/MapLibreLocationEngine.kt b/app/src/main/java/it/reyboz/bustorino/map/MapLibreLocationEngine.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/map/MapLibreLocationEngine.kt @@ -0,0 +1,114 @@ +package it.reyboz.bustorino.map + +import android.app.PendingIntent +import android.os.Looper +import android.util.Log +import org.maplibre.android.location.engine.LocationEngine +import org.maplibre.android.location.engine.LocationEngineCallback +import org.maplibre.android.location.engine.LocationEngineRequest +import org.maplibre.android.location.engine.LocationEngineResult + +import it.reyboz.bustorino.middleware.FusedNativeLocationProvider + +/** + * Adattatore che implementa l'interfaccia [LocationEngine] di MapLibre + * delegando a [FusedNativeLocationProvider]. + * + * Separa completamente la logica di fusione dei provider (in [FusedNativeLocationProvider]) + * dalla traduzione nel contratto MapLibre (qui). + * + * Uso: + * val provider = FusedNativeLocationProvider(context) + * val engine = MapLibreLocationEngine(provider) + * // poi passa engine a LocationComponentActivationOptions + */ +class MapLibreLocationEngine( + private val provider: FusedNativeLocationProvider, +) : LocationEngine { + + // Mappa callback MapLibre → listener del provider, per poterli rimuovere + private val callbackListeners = + HashMap, FusedNativeLocationProvider.LocationUpdateListener>() + + + override fun getLastLocation(callback: LocationEngineCallback) { + val location = provider.getLastLocationFromProviders() + if (location != null) { + callback.onSuccess(LocationEngineResult.create(location)) + } else { + callback.onFailure(NoLocationException()) + } + } + + // ------------------------------------------------------------------------- + // requestLocationUpdates — overload con Looper (quello usato da MapLibre) + // ------------------------------------------------------------------------- + + override fun requestLocationUpdates( + request: LocationEngineRequest, + callback: LocationEngineCallback, + looper: Looper?, + ) { + val providerListener = FusedNativeLocationProvider.LocationUpdateListener { location -> + callback.onSuccess(LocationEngineResult.create(location)) + } + + callbackListeners[callback] = providerListener + provider.addListener(providerListener) + + // Avvia (o riavvia) il provider con i parametri della request MapLibre. + // Se il provider è già attivo con altri listener, stop+start lo ri-configura. + provider.startUpdates( + FusedNativeLocationProvider.Options( + minIntervalMs = request.interval, + minDisplacementM = request.displacement, + looper = looper, + ) + ) + } + + // ------------------------------------------------------------------------- + // requestLocationUpdates — overload con PendingIntent (background/geofencing) + // ------------------------------------------------------------------------- + + override fun requestLocationUpdates( + request: LocationEngineRequest, + pendingIntent: PendingIntent, + ) { + // PendingIntent is used for background updates via BroadcastReceiver. + // FusedNativeLocationProvider operates in the foreground: delegating to PendingIntent + // would require a different architecture (LocationManager.requestLocationUpdates + // with native PendingIntent). Not supported in this implementation. + throw UnsupportedOperationException( + "MapLibreLocationEngine does not support updates via PendingIntent. " + + "Use requestLocationUpdates(request, callback, looper) or " + + "implement a dedicated BroadcastReceiver." + ) + } + + // ------------------------------------------------------------------------- + // removeLocationUpdates — overload con callback + // ------------------------------------------------------------------------- + + override fun removeLocationUpdates(callback: LocationEngineCallback) { + callbackListeners.remove(callback)?.let { providerListener -> + provider.removeListener(providerListener) + } + Log.d(DEBUG_TAG, "Removed location updates callback $callback") + } + + // ------------------------------------------------------------------------- + // removeLocationUpdates — overload con PendingIntent + // ------------------------------------------------------------------------- + + override fun removeLocationUpdates(pendingIntent: PendingIntent) { + throw UnsupportedOperationException( + "MapLibreLocationEngine does not support PendingIntent removal." + ) + } + + class NoLocationException : Exception() + companion object { + const val DEBUG_TAG = "BusTO-MapLocationEngine" + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt b/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt --- a/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt +++ b/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt @@ -213,9 +213,6 @@ Log.d(DEBUG_TAG, "Provider: $provider disabled") } - fun anyLocationProviderMatchesCriteria(cr: Criteria?): Boolean { - return Permissions.anyLocationProviderMatchesCriteria(locMan, cr, true) - } /** * Interface to be implemented to get the location request diff --git a/app/src/main/java/it/reyboz/bustorino/middleware/FusedNativeLocationProvider.kt b/app/src/main/java/it/reyboz/bustorino/middleware/FusedNativeLocationProvider.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/middleware/FusedNativeLocationProvider.kt @@ -0,0 +1,263 @@ +package it.reyboz.bustorino.middleware + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import it.reyboz.bustorino.BuildConfig +import it.reyboz.bustorino.util.Permissions +import java.util.concurrent.CopyOnWriteArraySet + +/** + * Native Android location provider that fuses GPS_PROVIDER, NETWORK_PROVIDER + * and PASSIVE_PROVIDER with no dependency on Google Play Services. + * + * Standalone class to be used anywhere in the app + * + */ +class FusedNativeLocationProvider(context: Context) { + + // ------------------------------------------------------------------------- + // Public interface for location update consumers + // ------------------------------------------------------------------------- + + fun interface LocationUpdateListener { + fun onLocationUpdate(location: Location) + } + + /** + * Configuration for location updates. + * + * @param minIntervalMs Minimum interval between updates in ms. + * @param minDisplacementM Minimum displacement in meters to trigger an update. + * @param looper Thread on which to receive callbacks. Null = main thread. + * @param useGps Enables GPS_PROVIDER. + * @param useNetwork Enables NETWORK_PROVIDER (WiFi + cell). + * @param usePassive Enables PASSIVE_PROVIDER (zero consumption, opportunistic updates). + */ + data class Options( + val minIntervalMs: Long = 500L, + val minDisplacementM: Float = 5f, + val looper: Looper? = null, + val useGps: Boolean = true, + val useNetwork: Boolean = true, + val usePassive: Boolean = true, + ) + + + private val locationManager = + context.applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + // List of registered listeners (called on the configured looper) + private val listeners = CopyOnWriteArraySet() + + // Active Android listeners, one per provider + private val activeAndroidListeners = mutableListOf() + + @Volatile + private var bestLocation: Location? = null + + @Volatile + private var running = false + + private var runningOptions = Options(500L, 5f, null, true, true, true) + + private val activeProviders = ArrayList() + + private var havePermissions = false + + //private val removedListener = mutableSetOf() + + private val handler by lazy { Handler(runningOptions.looper ?: Looper.getMainLooper()) } + + //private var appContext = context.applicationContext + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Adds a listener. Can be called before or after [startUpdates]. + */ + fun addListener(listener: LocationUpdateListener) { + if(BuildConfig.DEBUG) + Log.d(DEBUG_TAG, "Adding listener $listener") + synchronized(listeners) { + listeners.add(listener) + } + } + + /** + * Removes a previously registered listener. + */ + fun removeListener(listener: LocationUpdateListener) { + if(BuildConfig.DEBUG) + Log.d(DEBUG_TAG, "Removing listener $listener") + synchronized(listeners){ + if(listeners.remove(listener)){ + if(listeners.isEmpty()) stopUpdates() + } + if(BuildConfig.DEBUG) + Log.d(DEBUG_TAG, "Listener now size: ${listeners.size}") + } + } + + /** + * Starts receiving location updates from the enabled providers. + * If already running, stops the existing providers first and restarts + * them with the new configuration. + * + * Requires ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION. + */ + @SuppressLint("MissingPermission") + fun startUpdates(options: Options?): Boolean { + if (running) stopUpdates() + if (options!=null){ + runningOptions = options + } + val selectedProviders = buildList { + if (runningOptions.useGps) add(LocationManager.GPS_PROVIDER) + if (runningOptions.useNetwork) add(LocationManager.NETWORK_PROVIDER) + if (runningOptions.usePassive) add(LocationManager.PASSIVE_PROVIDER) + } + + val effectiveLooper = runningOptions.looper ?: Looper.getMainLooper() + + selectedProviders.forEach { provider -> + if (!locationManager.isProviderEnabled(provider)) return@forEach + + val locListener = LocationListener { location -> + if (isBetterLocation(location, bestLocation)) { + bestLocation = location + //Log.d(DEBUG_TAG, "New best location: $bestLocation") + notifyListeners(location) + } + } + + //runCatching { + locationManager.requestLocationUpdates( + provider, + runningOptions.minIntervalMs, + runningOptions.minDisplacementM, + locListener, + effectiveLooper, + ) + activeAndroidListeners.add(locListener) + activeProviders.add(provider) + //} + } + + running = activeAndroidListeners.isNotEmpty() + Log.d(DEBUG_TAG, "Started location updates, running: $running, with providers: $activeProviders") + return running + } + + /** + * Stops all updates and releases the Android listeners. + * [LocationUpdateListener]s registered via [addListener] are retained: + * calling [startUpdates] again will resume delivering updates to them. + */ + private fun stopUpdatesInternal() { + if(!running) //we have already done this + return + Log.d(DEBUG_TAG, "Actually stopping location updates, active providers: $activeProviders") + activeAndroidListeners.forEach { listener -> + runCatching { locationManager.removeUpdates(listener) } + } + activeAndroidListeners.clear() + running = false + activeProviders.clear() + } + + /** + * Returns the best known location cached by the enabled providers, + * without starting continuous updates. + * + * May return null if no provider has ever acquired a fix + * (e.g. first launch, device just turned on). + * + * Do not use if we do not have the Location permission + */ + @SuppressLint("MissingPermission") + fun getLastLocationFromProviders(): Location? { + val candidatesLocations = listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + ).mapNotNull { provider -> + locationManager.getLastKnownLocation(provider) + } + + // Among the candidates, the most accurate wins. On equal accuracy, + // the most recent wins. + return candidatesLocations.minWithOrNull( + compareBy({ it.accuracy }, { -it.time }) + ) + } + + //fun getLastReceivedBestLocation(): Location? { + // return bestLocation + //} + + + private fun notifyListeners(location: Location) { + //synchronized(listeners) { + listeners.forEach { it.onLocationUpdate(location) } + } + + /** + * Public call for stopping the updates + */ + fun stopUpdates() { + Log.d(DEBUG_TAG, "Stopping updates") + if (Looper.myLooper() == handler.looper) { + stopUpdatesInternal() + } else { + handler.post { stopUpdatesInternal() } + } + } + + + + companion object { + private const val TIME_DELAY = 2 * 60 * 1_000L // two minutes + private const val ACCURACY_DEGRADATION_THRESHOLD_M = 200f + private const val DEBUG_TAG = "BusTO-FusedLocationProv" + + /** + * Determines whether [candidate] is a better location than [current]. + * + * Criteria, in priority order: + * 1. If the candidate is newer than [TIME_DELAY], always accept it. + * 2. If it is significantly older, reject it. + * 3. Equal freshness: the one with lower accuracy (tighter radius) wins. + * 4. Same provider, same freshness delta, and not degrading too much: accept. + */ + @JvmStatic + fun isBetterLocation(candidate: Location, current: Location?): Boolean { + if (current == null) return true + + val timeDeltaMs = candidate.time - current.time + + return when { + timeDeltaMs > TIME_DELAY -> true // much more recent: accept immediately + timeDeltaMs < -TIME_DELAY -> false // much older: reject immediately + else -> { + val accuracyDeltaM = candidate.accuracy - current.accuracy + when { + accuracyDeltaM < 0 -> true // more accurate + accuracyDeltaM == 0f && timeDeltaMs > 0 -> true // same accuracy, fresher + timeDeltaMs > 0 + && accuracyDeltaM <= ACCURACY_DEGRADATION_THRESHOLD_M + && candidate.provider == current.provider -> true + else -> false + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/util/Permissions.java b/app/src/main/java/it/reyboz/bustorino/util/Permissions.java deleted file mode 100644 --- a/app/src/main/java/it/reyboz/bustorino/util/Permissions.java +++ /dev/null @@ -1,73 +0,0 @@ -package it.reyboz.bustorino.util; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.pm.PackageManager; -import android.location.Criteria; -import android.location.LocationManager; -import android.os.Build; -import android.util.Log; - -import androidx.annotation.RequiresApi; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; - -import java.util.List; - -public class Permissions { - final static public String DEBUG_TAG = "BusTO -Permissions"; - - final static public int PERMISSION_REQUEST_POSITION = 33; - final static public String LOCATION_PERMISSION_GIVEN = "loc_permission"; - final static public int STORAGE_PERMISSION_REQ = 291; - - final static public int PERMISSION_OK = 0; - final static public int PERMISSION_ASKING = 11; - final static public int PERMISSION_NEG_CANNOT_ASK = -3; - - final static public String[] LOCATION_PERMISSIONS={Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION}; - //final static public String[] NOTIFICATION_PERMISSION={Manifest.permission.POST_NOTIFICATIONS}; - - @RequiresApi(api = Build.VERSION_CODES.TIRAMISU) - public static String[] getNotificationPermissions(){ - return new String[]{Manifest.permission.POST_NOTIFICATIONS}; - } - - public static boolean anyLocationProviderMatchesCriteria(LocationManager mng, Criteria cr, boolean enabled) { - List providers = mng.getProviders(cr, enabled); - Log.d(DEBUG_TAG, "Getting enabled location providers: "); - for (String s : providers) { - Log.d(DEBUG_TAG, "Provider " + s); - } - return !providers.isEmpty(); - } - public static boolean isPermissionGranted(Context con,String permission){ - return ContextCompat.checkSelfPermission(con, permission) == PackageManager.PERMISSION_GRANTED; - } - - public static boolean bothLocationPermissionsGranted(Context con){ - return isPermissionGranted(con, Manifest.permission.ACCESS_FINE_LOCATION) && - isPermissionGranted(con, Manifest.permission.ACCESS_COARSE_LOCATION); - } - public static boolean anyLocationPermissionsGranted(Context con){ - return isPermissionGranted(con, Manifest.permission.ACCESS_FINE_LOCATION) || - isPermissionGranted(con, Manifest.permission.ACCESS_COARSE_LOCATION); - } - - public static void assertLocationPermissions(Context con, Activity activity) { - if(!isPermissionGranted(con, Manifest.permission.ACCESS_FINE_LOCATION) || - !isPermissionGranted(con,Manifest.permission.ACCESS_COARSE_LOCATION)){ - ActivityCompat.requestPermissions(activity,new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, PERMISSION_REQUEST_POSITION); - } - } - - /** - * Check if the system requires the POST_NOTIFICATION permission to send notifications - * @return true if required - */ - public static boolean isNotificationPermissionNeeded(){ - return (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU); - } -} diff --git a/app/src/main/java/it/reyboz/bustorino/util/Permissions.kt b/app/src/main/java/it/reyboz/bustorino/util/Permissions.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/util/Permissions.kt @@ -0,0 +1,156 @@ +package it.reyboz.bustorino.util + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.location.Criteria +import android.location.LocationManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.startActivity +import it.reyboz.bustorino.R +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.atomics.AtomicInt + +class Permissions private constructor(private val appContext: Context) { + + /* + @get:RequiresApi(api = Build.VERSION_CODES.TIRAMISU) + val notificationPermissions: Array + //final static public String[] NOTIFICATION_PERMISSION={Manifest.permission.POST_NOTIFICATIONS}; + get() = arrayOf(Manifest.permission.POST_NOTIFICATIONS) + + */ + private var askedTimesLocation = AtomicInteger(0) + + fun anyLocationProviderMatchesCriteria(mng: LocationManager, cr: Criteria, enabled: Boolean): Boolean { + val providers = mng.getProviders(cr, enabled) + Log.d(DEBUG_TAG, "Getting enabled location providers: ") + for (s in providers) { + Log.d(DEBUG_TAG, "Provider " + s) + } + return !providers.isEmpty() + } + + + fun checkRequestLocationPermissions(activity: Activity, launcher: ActivityResultLauncher>): Boolean { + + //activity.getSharedPreferences(, Context.MODE_PRIVATE) + var launched = false + if(shouldShowRequestPermissionRationale(activity,Manifest.permission.ACCESS_FINE_LOCATION)){ + Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_LONG).show() + } /*else{ + //cannot show the dialog anymore, go to the settings + openShowAppSettingsLocationDialog() + } + */ + val reqTimes = askedTimesLocation.getAndIncrement() + Log.d(DEBUG_TAG, "Requesting location permissions, asked ${reqTimes} times ") + if(reqTimes > 4){ + openShowAppSettingsLocationDialog() + } else{ + launcher.launch(LOCATION_PERMISSIONS) + launched = true + } + return launched + } + + /** + * Show alert dialog to enable location permission + */ + fun openShowAppSettingsLocationDialog() { + val context = appContext + val builder = AlertDialog.Builder(context) + + builder.setTitle(R.string.no_permission_dialog_title) + builder.setMessage(R.string.no_permission_dialog_text_location) + builder.setPositiveButton( + R.string.no_permission_dialog_open, + DialogInterface.OnClickListener { dialogInterface: DialogInterface?, i: Int -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.setData(Uri.fromParts("package", context.getPackageName(), null)) + context.startActivity(intent) + }) + builder.setNegativeButton(android.R.string.cancel, null) + builder.show() + } + + + fun assertLocationPermissions(con: Context, activity: Activity) { + if (!isPermissionGranted(con, Manifest.permission.ACCESS_FINE_LOCATION) || + !isPermissionGranted(con, Manifest.permission.ACCESS_COARSE_LOCATION) + ) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + PERMISSION_REQUEST_POSITION + ) + } + } + + + + companion object{ + const val DEBUG_TAG: String = "BusTO -Permissions" + + const val PERMISSION_REQUEST_POSITION: Int = 33 + const val LOCATION_PERMISSION_GIVEN: String = "loc_permission" + const val STORAGE_PERMISSION_REQ: Int = 291 + + const val PERMISSION_OK: Int = 0 + const val PERMISSION_ASKING: Int = 11 + const val PERMISSION_NEG_CANNOT_ASK: Int = -3 + + @JvmField + val LOCATION_PERMISSIONS: Array = arrayOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + + @JvmStatic + fun isPermissionGranted(con: Context, permission: String): Boolean { + return ContextCompat.checkSelfPermission(con, permission) == PackageManager.PERMISSION_GRANTED + } + @JvmStatic + fun bothLocationPermissionsGranted(con: Context): Boolean { + return isPermissionGranted(con, Manifest.permission.ACCESS_FINE_LOCATION) && + isPermissionGranted(con, Manifest.permission.ACCESS_COARSE_LOCATION) + } + + @JvmStatic + fun anyLocationPermissionsGranted(con: Context): Boolean { + return isPermissionGranted(con, Manifest.permission.ACCESS_FINE_LOCATION) || + isPermissionGranted(con, Manifest.permission.ACCESS_COARSE_LOCATION) + } + + /** + * Check if the system requires the POST_NOTIFICATION permission to send notifications + * @return true if required + */ + @JvmStatic + fun isNotificationPermissionNeeded() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + + + + @Volatile + private var instance: Permissions? = null + + fun getInstance(context: Context) = + instance ?: synchronized(this) { + instance ?: Permissions(context.applicationContext).also { instance = it } + } + + } +} diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt @@ -1,3 +1,20 @@ +/* + BusTO - View Model 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.viewmodels import android.app.Application diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt @@ -1,5 +1,6 @@ package it.reyboz.bustorino.viewmodels +import android.location.Location import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import it.reyboz.bustorino.map.MapCameraState @@ -33,6 +34,11 @@ return restoreMapState(map, this.savedCameraState) } + var locationToShow: Location? = null + + val locationActive = MutableLiveData(false) + val followingUserPosition = MutableLiveData(false) + companion object{ fun restoreMapState(map: MapLibreMap, savedCameraState: MapCameraState?): Boolean { val state = savedCameraState ?: return false diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/NearbyStopsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/NearbyStopsViewModel.kt --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/NearbyStopsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/NearbyStopsViewModel.kt @@ -1,3 +1,20 @@ +/* + BusTO - View Model 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 diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt @@ -1,3 +1,20 @@ +/* + BusTO - View Model components + Copyright (C) 2026 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 diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt @@ -1,3 +1,20 @@ +/* + BusTO - View Model 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.viewmodels import android.app.Application @@ -81,9 +98,11 @@ addStopsCallback) } } + + //this is only saved at the end, is it really necessary? var lastUserLocation: Location? = null companion object{ private const val DEBUG_TAG = "BusTOStopMapViewModel" } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -175,6 +175,7 @@ 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 @@ -238,6 +239,7 @@ 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. @@ -342,6 +344,10 @@ 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 can not only be granted in the system settings. + Open settings OK, close the tutorial Close the tutorial