/*
* 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
binding.buttonLocationRouteStart.isEnabled = state.route != null
binding.checkboxRouteLoop.isEnabled = state.route != null
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.SPECIFIC_LOCATION) {
binding.mapLoader.isVisible = false
binding.mapOverlay.isVisible = false
binding.centeredMarker.isVisible = true
mapboxMap?.moveCamera(
CameraUpdateFactory.newLatLng(
LatLng(
state.specificLatitude?.toDouble() ?: 0.0,
state.specificLongitude?.toDouble() ?: 0.0
)
)
)
} else if(state.mode == LocationMode.ROUTE) {
binding.mapLoader.isVisible = false
binding.mapOverlay.isVisible = true
binding.centeredMarker.isVisible = false
state.route?.let {
mapboxMap?.moveCamera(
CameraUpdateFactory.newLatLng(
LatLng(
state.route.first().latitude.toDouble(),
state.route.first().longitude.toDouble()
)
)
)
}
} else {
binding.centeredMarker.isVisible = false
}
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(state.route == null) {
binding.locationRoutePath.text = getString(R.string.location_route_no_route)
} else {
binding.locationRoutePath.text = getString(R.string.location_route_valid)
}
}
@SuppressLint("MissingPermission")
private fun updateLocation(lastLocation: Location?, mode: LocationMode) {
lastLocation?.let { location ->
locationComponent?.isLocationComponentEnabled = true
locationComponent?.forceLocationUpdate(location)
if (mode != LocationMode.SPECIFIC_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.SPECIFIC_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
}
}