/* * Copyright (C) 2024 Leonard Kugis * Copyright (C) 2023 MURENA SAS * Copyright (C) 2021 E FOUNDATION * * 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 foundation.e.advancedprivacy.features.location import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.location.Location import android.os.Bundle import android.text.Editable import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.NonNull import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import android.app.Activity import android.content.Intent import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM import com.google.android.material.textfield.TextInputLayout.END_ICON_NONE import com.mapbox.android.gestures.MoveGestureDetector import com.mapbox.mapboxsdk.Mapbox import com.mapbox.mapboxsdk.WellKnownTileServer import com.mapbox.mapboxsdk.camera.CameraPosition import com.mapbox.mapboxsdk.camera.CameraUpdateFactory import com.mapbox.mapboxsdk.geometry.LatLng import com.mapbox.mapboxsdk.location.LocationComponent import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions import com.mapbox.mapboxsdk.location.modes.CameraMode import com.mapbox.mapboxsdk.location.modes.RenderMode import com.mapbox.mapboxsdk.maps.MapboxMap import com.mapbox.mapboxsdk.maps.Style import foundation.e.advancedprivacy.R import foundation.e.advancedprivacy.common.NavToolbarFragment import foundation.e.advancedprivacy.databinding.FragmentFakeLocationBinding import foundation.e.advancedprivacy.domain.entities.LocationMode import foundation.e.advancedprivacy.features.location.FakeLocationViewModel.Action import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import timber.log.Timber import com.google.gson.Gson import com.google.gson.reflect.TypeToken import foundation.e.advancedprivacy.domain.entities.FakeLocationCoordinate import java.io.BufferedReader import java.io.IOException import java.io.InputStream import java.io.InputStreamReader import android.util.Log import kotlin.math.sqrt import kotlin.math.pow class FakeLocationFragment : NavToolbarFragment(R.layout.fragment_fake_location) { private var isFirstLaunch: Boolean = true private val viewModel: FakeLocationViewModel by viewModel() private var _binding: FragmentFakeLocationBinding? = null private val binding get() = _binding!! private var mapboxMap: MapboxMap? = null private var locationComponent: LocationComponent? = null private var inputJob: Job? = null private var updateLocationJob: Job? = null private val locationPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> if (permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) || permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) ) { viewModel.submitAction(Action.StartListeningLocation) } // TODO: else. } companion object { private const val MAP_STYLE = "mapbox://styles/mapbox/outdoors-v12" } override fun onAttach(context: Context) { super.onAttach(context) Mapbox.getInstance(requireContext(), getString(R.string.mapbox_key), WellKnownTileServer.Mapbox) } private fun displayToast(message: String) { Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) .show() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentFakeLocationBinding.bind(view) binding.mapView.setup(savedInstanceState) { mapboxMap -> this.mapboxMap = mapboxMap mapboxMap.uiSettings.isRotateGesturesEnabled = false mapboxMap.setStyle(MAP_STYLE) { style -> enableLocationPlugin(style) mapboxMap.addOnMoveListener(onMoveListener) mapboxMap.cameraPosition = CameraPosition.Builder().zoom(8.0).build() // Bind click listeners once map is ready. bindClickListeners() render(viewModel.state.value) startUpdateLocationJob() } } startListening() } private val onMoveListener = object : MapboxMap.OnMoveListener { private val cameraIdleListener: MapboxMap.OnCameraIdleListener = object : MapboxMap.OnCameraIdleListener { override fun onCameraIdle() { mapboxMap?.cameraPosition?.target?.let { viewModel.submitAction( Action.SetSpecificLocationAction( it.latitude.toFloat(), it.longitude.toFloat() ) ) startUpdateLocationJob() } mapboxMap?.removeOnCameraIdleListener(this) } } override fun onMoveBegin(detector: MoveGestureDetector) { updateLocationJob?.cancel() updateLocationJob = null mapboxMap?.removeOnCameraIdleListener(cameraIdleListener) } override fun onMove(detector: MoveGestureDetector) {} override fun onMoveEnd(detector: MoveGestureDetector) { mapboxMap?.addOnCameraIdleListener(cameraIdleListener) } } private fun startListening() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { render(viewModel.state.value) viewModel.state.collect(::render) } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.singleEvents.collect { event -> when (event) { is FakeLocationViewModel.SingleEvent.ErrorEvent -> { displayToast(event.error) } is FakeLocationViewModel.SingleEvent.RequestLocationPermission -> { // TODO for standalone: rationale dialog locationPermissionRequest.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } } } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.doOnStartedState() } } } private fun startUpdateLocationJob() { updateLocationJob?.cancel() updateLocationJob = viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { // Without this delay, onResume, map apply the updateLocation and then // move to an old fake location. delay(1000) viewModel.currentLocation.collect { location -> updateLocation(location, viewModel.state.value.mode) } } } } private fun validateBounds(inputLayout: TextInputLayout, minValue: Float, maxValue: Float): Boolean { return try { val value = inputLayout.editText?.text?.toString()?.toFloat()!! if (value > maxValue || value < minValue) { throw NumberFormatException("value $value is out of bounds") } inputLayout.error = null inputLayout.setEndIconDrawable(R.drawable.ic_valid) inputLayout.endIconMode = END_ICON_CUSTOM true } catch (e: Exception) { inputLayout.endIconMode = END_ICON_NONE inputLayout.error = getString(R.string.location_error_bounds) false } } private fun validateCoordinate( inputLayout: TextInputLayout, maxValue: Float ): Boolean { return try { val value = inputLayout.editText?.text?.toString()?.toFloat()!! if (value > maxValue || value < -maxValue) { throw NumberFormatException("value $value is out of bounds") } inputLayout.error = null inputLayout.setEndIconDrawable(R.drawable.ic_valid) inputLayout.endIconMode = END_ICON_CUSTOM true } catch (e: Exception) { inputLayout.endIconMode = END_ICON_NONE inputLayout.error = getString(R.string.location_input_error) false } } private fun updateSpecificCoordinates() { try { val lat = binding.edittextLatitude.text.toString().toFloat() val lon = binding.edittextLongitude.text.toString().toFloat() if (lat <= 90f && lat >= -90f && lon <= 180f && lon >= -180f) { viewModel.submitAction( Action.SetSpecificLocationAction( lat, lon ) ) } } catch (e: NumberFormatException) { Timber.e("Unfiltered wrong lat lon format") } } private fun updateMockLocationParameters() { try { viewModel.submitAction( Action.UpdateMockLocationParameters( binding.edittextAltitude.text.toString().toFloat(), binding.edittextSpeed.text.toString().toFloat(), binding.edittextJitter.text.toString().toFloat(), ) ) } catch (e: NumberFormatException) { Timber.e("Unfiltered wrong altitude/speed/jitter format") } } @Suppress("UNUSED_PARAMETER") private fun onAltitudeTextChanged(editable: Editable?) { if(!validateBounds(binding.textlayoutAltitude, -100000.0f, 100000.0f)) return updateMockLocationParameters() } @Suppress("UNUSED_PARAMETER") private fun onSpeedTextChanged(editable: Editable?) { if(!validateBounds(binding.textlayoutSpeed, 0.0f, 299792458.0f)) return updateMockLocationParameters() } @Suppress("UNUSED_PARAMETER") private fun onJitterTextChanged(editable: Editable?) { if(!validateBounds(binding.textlayoutJitter, 0.0f, 10000000.0f)) return updateMockLocationParameters() } @Suppress("UNUSED_PARAMETER") private fun onLatTextChanged(editable: Editable?) { if (!binding.edittextLatitude.isFocused || !validateCoordinate(binding.textlayoutLatitude, 90f) ) return updateSpecificCoordinates() } @Suppress("UNUSED_PARAMETER") private fun onLonTextChanged(editable: Editable?) { if (!binding.edittextLongitude.isFocused || !validateCoordinate(binding.textlayoutLongitude, 180f) ) return updateSpecificCoordinates() } private val isEditingLatLon get() = binding.edittextLongitude.isFocused || binding.edittextLatitude.isFocused private val latLonOnFocusChangeListener = object : View.OnFocusChangeListener { override fun onFocusChange(v: View?, hasFocus: Boolean) { if (!isEditingLatLon && !binding.edittextAltitude.isFocused && !binding.edittextSpeed.isFocused && !binding.edittextJitter.isFocused) { (context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow( v?.windowToken, 0 ) } } } private var route: List? = null private val filePickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { result.data?.data?.let { uri -> var activity = getActivity() if(uri.path != null && activity != null) { var inputStream: InputStream? = null var reader: BufferedReader? = null var route_str: String? = null try { inputStream = activity.contentResolver.openInputStream(uri) reader = BufferedReader(InputStreamReader(inputStream)) route_str = reader.readLines().joinToString("") } catch(e: IOException) { Log.e("FakeLocationFragment", "Error reading JSON file", e) } finally { try { reader?.close() inputStream?.close() } catch (e: IOException) { Log.e("FakeLocationFragment", "Error closing streams", e) } } route = Gson().fromJson(route_str, object : TypeToken>() {}.type) var route_buf = route route_buf?.let { viewModel.submitAction(Action.SetRoute(route_buf)) } } } } } private fun openFilePicker() { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" } filePickerLauncher.launch(intent) } @SuppressLint("ClickableViewAccessibility") private fun bindClickListeners() { binding.radioUseRealLocation.setOnClickListener { viewModel.submitAction(Action.UseRealLocationAction) } binding.radioUseRandomLocation.setOnClickListener { viewModel.submitAction(Action.UseRandomLocationAction) } binding.radioUseSpecificLocation.setOnClickListener { mapboxMap?.cameraPosition?.target?.let { viewModel.submitAction( Action.SetSpecificLocationAction(it.latitude.toFloat(), it.longitude.toFloat()) ) } } binding.radioUseRoute.setOnClickListener { viewModel.submitAction(Action.UseRoute) } binding.edittextAltitude.addTextChangedListener(afterTextChanged = ::onAltitudeTextChanged) binding.edittextSpeed.addTextChangedListener(afterTextChanged = ::onSpeedTextChanged) binding.edittextJitter.addTextChangedListener(afterTextChanged = ::onJitterTextChanged) binding.edittextLatitude.addTextChangedListener(afterTextChanged = ::onLatTextChanged) binding.edittextLongitude.addTextChangedListener(afterTextChanged = ::onLonTextChanged) binding.edittextAltitude.onFocusChangeListener = latLonOnFocusChangeListener binding.edittextSpeed.onFocusChangeListener = latLonOnFocusChangeListener binding.edittextJitter.onFocusChangeListener = latLonOnFocusChangeListener binding.edittextLatitude.onFocusChangeListener = latLonOnFocusChangeListener binding.edittextLongitude.onFocusChangeListener = latLonOnFocusChangeListener binding.buttonLocationRoutePathSelect.setOnClickListener { openFilePicker() } binding.checkboxRouteLoop.setOnCheckedChangeListener { _, isChecked -> viewModel.submitAction(Action.SetRouteLoopEnabledAction(isChecked)) } binding.buttonLocationRouteStart.setOnClickListener { viewModel.submitAction(Action.RouteStartAction) } binding.buttonLocationRouteStop.setOnClickListener { viewModel.submitAction(Action.RouteStopAction) } } @SuppressLint("MissingPermission") private fun render(state: FakeLocationState) { binding.radioUseRandomLocation.isChecked = state.mode == LocationMode.RANDOM_LOCATION binding.radioUseSpecificLocation.isChecked = state.mode == LocationMode.SPECIFIC_LOCATION binding.radioUseRealLocation.isChecked = state.mode == LocationMode.REAL_LOCATION binding.radioUseRoute.isChecked = state.mode == LocationMode.ROUTE binding.mapView.isEnabled = (state.mode == LocationMode.SPECIFIC_LOCATION) binding.textlayoutAltitude.isVisible = state.mode == LocationMode.SPECIFIC_LOCATION binding.textlayoutSpeed.isVisible = state.mode == LocationMode.SPECIFIC_LOCATION binding.textlayoutJitter.isVisible = state.mode == LocationMode.SPECIFIC_LOCATION binding.buttonLocationRoutePathSelect.isVisible = state.mode == LocationMode.ROUTE binding.locationRoutePath.isVisible = state.mode == LocationMode.ROUTE binding.checkboxRouteLoop.isVisible = state.mode == LocationMode.ROUTE binding.buttonLocationRouteStart.isVisible = state.mode == LocationMode.ROUTE binding.buttonLocationRouteStop.isVisible = state.mode == LocationMode.ROUTE if(binding.checkboxRouteLoop.isVisible) binding.checkboxRouteLoop.isChecked = state.loopRoute if(!binding.edittextAltitude.isFocused) binding.edittextAltitude.setText(state.altitude?.toString()) if(!binding.edittextSpeed.isFocused) binding.edittextSpeed.setText(state.speed?.toString()) if(!binding.edittextJitter.isFocused) binding.edittextJitter.setText(state.jitter?.toString()) if (state.mode == LocationMode.REAL_LOCATION) { binding.centeredMarker.isVisible = false } else { binding.mapLoader.isVisible = false binding.mapOverlay.isVisible = state.mode != LocationMode.SPECIFIC_LOCATION binding.centeredMarker.isVisible = true mapboxMap?.moveCamera( CameraUpdateFactory.newLatLng( LatLng( state.specificLatitude?.toDouble() ?: 0.0, state.specificLongitude?.toDouble() ?: 0.0 ) ) ) } binding.textlayoutLatitude.isVisible = (state.mode == LocationMode.SPECIFIC_LOCATION) binding.textlayoutLongitude.isVisible = (state.mode == LocationMode.SPECIFIC_LOCATION) if (!isEditingLatLon) { binding.edittextLatitude.setText(state.specificLatitude?.toString()) binding.edittextLongitude.setText(state.specificLongitude?.toString()) } if(route == null) { binding.locationRoutePath.text = "No valid route selected" } else { binding.locationRoutePath.text = "Route valid" } } @SuppressLint("MissingPermission") private fun updateLocation(lastLocation: Location?, mode: LocationMode) { lastLocation?.let { location -> locationComponent?.isLocationComponentEnabled = true locationComponent?.forceLocationUpdate(location) if (mode == LocationMode.REAL_LOCATION) { binding.mapLoader.isVisible = false binding.mapOverlay.isVisible = false val update = CameraUpdateFactory.newLatLng( LatLng(location.latitude, location.longitude) ) if (isFirstLaunch) { mapboxMap?.moveCamera(update) isFirstLaunch = false } else { mapboxMap?.animateCamera(update) } } } ?: run { locationComponent?.isLocationComponentEnabled = false if (mode == LocationMode.REAL_LOCATION) { binding.mapLoader.isVisible = true binding.mapOverlay.isVisible = true } } } @SuppressLint("MissingPermission") private fun enableLocationPlugin(@NonNull loadedMapStyle: Style) { // Check if permissions are enabled and if not request locationComponent = mapboxMap?.locationComponent locationComponent?.activateLocationComponent( LocationComponentActivationOptions.builder( requireContext(), loadedMapStyle ).useDefaultLocationEngine(false).build() ) locationComponent?.isLocationComponentEnabled = true locationComponent?.cameraMode = CameraMode.NONE locationComponent?.renderMode = RenderMode.NORMAL } override fun onStart() { super.onStart() binding.mapView.onStart() } override fun onResume() { super.onResume() viewModel.submitAction(Action.StartListeningLocation) binding.mapView.onResume() } override fun onPause() { super.onPause() viewModel.submitAction(Action.StopListeningLocation) binding.mapView.onPause() } override fun onStop() { super.onStop() binding.mapView.onStop() } override fun onLowMemory() { super.onLowMemory() binding.mapView.onLowMemory() } override fun onDestroyView() { super.onDestroyView() binding.mapView.onDestroy() mapboxMap = null locationComponent = null inputJob = null _binding = null } }