From 204c625ea307d30026b43cfe2fe4076aacb7e099 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 8 May 2021 01:08:41 +0530 Subject: Add random city array and fix issue with location picking with map --- .../e/privacycentralapp/dummy/CityDataSource.kt | 65 +++++ .../e/privacycentralapp/dummy/DummyDataSource.kt | 5 +- .../features/location/FakeLocationFeature.kt | 93 +++++-- .../features/location/FakeLocationFragment.kt | 276 ++++++++++++--------- app/src/main/res/layout/fragment_fake_location.xml | 75 +++--- app/src/main/res/values/arrays.xml | 32 +++ 6 files changed, 367 insertions(+), 179 deletions(-) create mode 100644 app/src/main/java/foundation/e/privacycentralapp/dummy/CityDataSource.kt create mode 100644 app/src/main/res/values/arrays.xml diff --git a/app/src/main/java/foundation/e/privacycentralapp/dummy/CityDataSource.kt b/app/src/main/java/foundation/e/privacycentralapp/dummy/CityDataSource.kt new file mode 100644 index 0000000..3bb2f12 --- /dev/null +++ b/app/src/main/java/foundation/e/privacycentralapp/dummy/CityDataSource.kt @@ -0,0 +1,65 @@ +/* + * 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.privacycentralapp.dummy + +import kotlin.random.Random + +data class City(val name: String, val latitude: Double, val longitude: Double) { + + fun toRandomLocation(): Location { + return Location(LocationMode.RANDOM_LOCATION, this.latitude, this.longitude) + } +} + +object CityDataSource { + private val BARCELONA = Pair(41.3851, 2.1734) + private val BUDAPEST = Pair(47.4979, 19.0402) + private val ABU_DHABI = Pair(24.4539, 54.3773) + private val HYDERABAD = Pair(17.3850, 78.4867) + private val QUEZON_CITY = Pair(14.6760, 121.0437) + private val PARIS = Pair(48.8566, 2.3522) + private val LONDON = Pair(51.5074, 0.1278) + private val SHANGHAI = Pair(31.2304, 121.4737) + private val MADRID = Pair(40.4168, 3.7038) + private val LAHORE = Pair(31.5204, 74.3587) + private val CHICAGO = Pair(41.8781, 87.6298) + + // LatLong Array, the order should be the same as that of R.array.cities + private val latLongArray = arrayOf( + BARCELONA, + BUDAPEST, + ABU_DHABI, + HYDERABAD, + QUEZON_CITY, + PARIS, + LONDON, + SHANGHAI, + MADRID, + LAHORE, + CHICAGO + ) + + fun getRandomCity(cities: Array): City { + if (cities.size != latLongArray.size) { + throw IllegalStateException("LatLong array must have the same number of element as in cities array.") + } + val randomIndex = Random.nextInt(cities.size) + val latLong = latLongArray[randomIndex] + return City(cities[randomIndex], latLong.first, latLong.second) + } +} diff --git a/app/src/main/java/foundation/e/privacycentralapp/dummy/DummyDataSource.kt b/app/src/main/java/foundation/e/privacycentralapp/dummy/DummyDataSource.kt index aef994b..fe61354 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/dummy/DummyDataSource.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/dummy/DummyDataSource.kt @@ -185,7 +185,10 @@ object DummyDataSource { LocationMode.REAL_LOCATION -> _location.value = Location(LocationMode.REAL_LOCATION, 24.39, 71.80) - LocationMode.RANDOM_LOCATION -> _location.value = randomLocation() + LocationMode.RANDOM_LOCATION -> { + requireNotNull(location) { "Custom location should be null" } + _location.value = location + } LocationMode.CUSTOM_LOCATION -> { requireNotNull(location) { "Custom location should be null" } _location.value = location.copy(mode = LocationMode.CUSTOM_LOCATION) diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFeature.kt b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFeature.kt index a11619a..d94f71c 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFeature.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFeature.kt @@ -18,16 +18,17 @@ package foundation.e.privacycentralapp.features.location import android.util.Log +import com.mapbox.mapboxsdk.geometry.LatLng import foundation.e.flowmvi.Actor import foundation.e.flowmvi.Reducer import foundation.e.flowmvi.SingleEventProducer import foundation.e.flowmvi.feature.BaseFeature +import foundation.e.privacycentralapp.dummy.CityDataSource import foundation.e.privacycentralapp.dummy.DummyDataSource import foundation.e.privacycentralapp.dummy.Location import foundation.e.privacycentralapp.dummy.LocationMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map // Define a state machine for Fake location feature class FakeLocationFeature( @@ -44,10 +45,7 @@ class FakeLocationFeature( { message -> Log.d("FakeLocationFeature", message) }, singleEventProducer ) { - sealed class State { - object InitialState : State() - data class LocationState(val location: Location) : State() - } + data class State(val location: Location) sealed class SingleEvent { object RandomLocationSelectedEvent : SingleEvent() @@ -57,47 +55,91 @@ class FakeLocationFeature( } sealed class Action { - object ObserveLocationAction : Action() + data class UpdateLocationAction(val latLng: LatLng) : Action() object UseRealLocationAction : Action() - object UseRandomLocationAction : Action() + data class UseRandomLocationAction(val cities: Array) : Action() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UseRandomLocationAction + + if (!cities.contentEquals(other.cities)) return false + + return true + } + + override fun hashCode(): Int { + return cities.contentHashCode() + } + } + object UseSpecificLocationAction : Action() - data class AddSpecificLocationAction(val latitude: Double, val longitude: Double) : Action() + data class SetFakeLocationAction(val latitude: Double, val longitude: Double) : Action() } sealed class Effect { - data class LocationUpdatedEffect(val location: Location) : Effect() + data class LocationUpdatedEffect(val latitude: Double, val longitude: Double) : Effect() object RealLocationSelectedEffect : Effect() object RandomLocationSelectedEffect : Effect() - data class SpecificLocationSelectedEffect(val location: Location) : Effect() + object SpecificLocationSelectedEffect : Effect() object SpecificLocationSavedEffect : Effect() data class ErrorEffect(val message: String) : Effect() } companion object { fun create( - initialState: State = State.InitialState, + initialState: State = State( + Location( + LocationMode.REAL_LOCATION, + 0.0, + 0.0 + ) + ), coroutineScope: CoroutineScope ) = FakeLocationFeature( initialState, coroutineScope, reducer = { state, effect -> when (effect) { - Effect.RandomLocationSelectedEffect, - Effect.RealLocationSelectedEffect, is Effect.ErrorEffect, Effect.SpecificLocationSavedEffect -> state - is Effect.LocationUpdatedEffect -> State.LocationState(effect.location) - is Effect.SpecificLocationSelectedEffect -> State.LocationState(effect.location) + Effect.RandomLocationSelectedEffect -> state.copy( + location = state.location.copy( + mode = LocationMode.RANDOM_LOCATION + ) + ) + Effect.RealLocationSelectedEffect -> state.copy( + location = state.location.copy( + mode = LocationMode.REAL_LOCATION + ) + ) + is Effect.ErrorEffect, Effect.SpecificLocationSavedEffect -> state + is Effect.LocationUpdatedEffect -> state.copy( + location = state.location.copy( + latitude = effect.latitude, + longitude = effect.longitude + ) + ) + is Effect.SpecificLocationSelectedEffect -> state.copy( + location = state.location.copy( + mode = LocationMode.CUSTOM_LOCATION + ) + ) } }, actor = { _, action -> when (action) { - is Action.ObserveLocationAction -> DummyDataSource.location.map { - Effect.LocationUpdatedEffect(it) - } - is Action.AddSpecificLocationAction -> { + is Action.UpdateLocationAction -> flowOf( + Effect.LocationUpdatedEffect( + action.latLng.latitude, + action.latLng.longitude + ) + ) + is Action.SetFakeLocationAction -> { val location = Location( LocationMode.CUSTOM_LOCATION, action.latitude, action.longitude ) + // TODO: Call fake location api with specific coordinates here. val success = DummyDataSource.setLocationMode( LocationMode.CUSTOM_LOCATION, location @@ -112,8 +154,13 @@ class FakeLocationFeature( ) } } - Action.UseRandomLocationAction -> { - val success = DummyDataSource.setLocationMode(LocationMode.RANDOM_LOCATION) + is Action.UseRandomLocationAction -> { + val randomCity = CityDataSource.getRandomCity(action.cities) + // TODO: Call fake location api with random location here. + val success = DummyDataSource.setLocationMode( + LocationMode.RANDOM_LOCATION, + randomCity.toRandomLocation() + ) if (success) { flowOf( Effect.RandomLocationSelectedEffect @@ -125,6 +172,7 @@ class FakeLocationFeature( } } Action.UseRealLocationAction -> { + // TODO: Call turn off fake location api here. val success = DummyDataSource.setLocationMode(LocationMode.REAL_LOCATION) if (success) { flowOf( @@ -137,8 +185,7 @@ class FakeLocationFeature( } } Action.UseSpecificLocationAction -> { - val location = DummyDataSource.location.value - flowOf(Effect.SpecificLocationSelectedEffect(location.copy(mode = LocationMode.CUSTOM_LOCATION))) + flowOf(Effect.SpecificLocationSelectedEffect) } } }, diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt index 24ea426..96bebb7 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt @@ -20,47 +20,47 @@ package foundation.e.privacycentralapp.features.location import android.annotation.SuppressLint import android.content.Context import android.os.Bundle +import android.os.Looper import android.text.Editable import android.util.Log import android.view.Gravity import android.view.View import android.view.ViewGroup -import android.widget.Button +import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageView import android.widget.RadioButton import android.widget.Toast import android.widget.Toolbar import androidx.annotation.NonNull -import androidx.core.content.res.ResourcesCompat +import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout +import com.mapbox.android.core.location.LocationEngineCallback +import com.mapbox.android.core.location.LocationEngineRequest +import com.mapbox.android.core.location.LocationEngineResult import com.mapbox.android.core.permissions.PermissionsListener import com.mapbox.android.core.permissions.PermissionsManager import com.mapbox.mapboxsdk.Mapbox +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.LocationUpdate 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.OnMapReadyCallback import com.mapbox.mapboxsdk.maps.Style -import com.mapbox.mapboxsdk.style.layers.Property.NONE -import com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconAllowOverlap -import com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconIgnorePlacement -import com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconImage -import com.mapbox.mapboxsdk.style.layers.PropertyFactory.visibility -import com.mapbox.mapboxsdk.style.layers.SymbolLayer -import com.mapbox.mapboxsdk.style.sources.GeoJsonSource import foundation.e.flowmvi.MVIView import foundation.e.privacycentralapp.R import foundation.e.privacycentralapp.dummy.LocationMode +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -69,18 +69,66 @@ class FakeLocationFragment : MVIView, PermissionsListener { + private var isCameraMoved: Boolean = false private lateinit var permissionsManager: PermissionsManager private val viewModel: FakeLocationViewModel by viewModels() private lateinit var mapView: FakeLocationMapView private lateinit var mapboxMap: MapboxMap + private lateinit var useRealLocationRadioBtn: RadioButton + private lateinit var useRandomLocationRadioBtn: RadioButton + private lateinit var useSpecificLocationRadioBtn: RadioButton + private lateinit var latEditText: EditText + private lateinit var longEditText: EditText + private var hoveringMarker: ImageView? = null - private var mutableLatLongFlow = MutableStateFlow(LatLng()) - private var latLong = mutableLatLongFlow.asStateFlow() + private var inputJob: Job? = null + + // Callback which updates the map in realtime. + private val locationChangeCallback: LocationEngineCallback = + object : LocationEngineCallback { + override fun onSuccess(result: LocationEngineResult?) { + result?.lastLocation?.let { + Log.d(TAG, "Last location: ${it.latitude}, ${it.longitude}") + mapboxMap.locationComponent.forceLocationUpdate( + LocationUpdate.Builder().location(it).animationDuration(200) + .build() + ) + if (!isCameraMoved) { + mapboxMap.animateCamera( + CameraUpdateFactory.newLatLng( + LatLng( + it.latitude, + it.longitude + ) + ) + ) + } + // Only update location when location mode is set to real location + if (viewModel.fakeLocationFeature.state.value.location.mode != LocationMode.CUSTOM_LOCATION) { + viewModel.submitAction( + FakeLocationFeature.Action.UpdateLocationAction( + LatLng( + it.latitude, + it.longitude + ) + ) + ) + } + } + } + + override fun onFailure(exception: Exception) { + Log.e(TAG, "${exception.message}") + } + } companion object { - private const val DROPPED_MARKER_LAYER_ID = "DROPPED_MARKER_LAYER_ID" + private const val DEBOUNCE_PERIOD = 1000L + private const val TAG = "FakeLocationFragment" + private const val DEFAULT_INTERVAL_IN_MILLISECONDS = 1000L + private const val DEFAULT_MAX_WAIT_TIME = DEFAULT_INTERVAL_IN_MILLISECONDS * 5 } override fun onCreate(savedInstanceState: Bundle?) { @@ -91,16 +139,28 @@ class FakeLocationFragment : lifecycleScope.launchWhenStarted { viewModel.fakeLocationFeature.singleEvents.collect { event -> when (event) { - is FakeLocationFeature.SingleEvent.RandomLocationSelectedEvent -> displayToast("Random location selected") - is FakeLocationFeature.SingleEvent.SpecificLocationSavedEvent -> displayToast("Specific location selected") - is FakeLocationFeature.SingleEvent.ErrorEvent -> displayToast(event.error) - FakeLocationFeature.SingleEvent.RealLocationSelectedEvent -> displayToast("Real location selected") + is FakeLocationFeature.SingleEvent.RandomLocationSelectedEvent -> { + displayToast("Random location selected") + hoveringMarker?.visibility = View.GONE + isCameraMoved = false + } + is FakeLocationFeature.SingleEvent.SpecificLocationSavedEvent -> { + // Hide camera hover marker when custom location is picked from map. + hoveringMarker?.visibility = View.GONE + isCameraMoved = false + } + is FakeLocationFeature.SingleEvent.ErrorEvent -> { + displayToast(event.error) + isCameraMoved = false + } + FakeLocationFeature.SingleEvent.RealLocationSelectedEvent -> { + displayToast("Real location selected") + hoveringMarker?.visibility = View.GONE + isCameraMoved = false + } } } } - lifecycleScope.launchWhenStarted { - viewModel.submitAction(FakeLocationFeature.Action.ObserveLocationAction) - } } override fun onAttach(context: Context) { @@ -117,12 +177,12 @@ class FakeLocationFragment : super.onViewCreated(view, savedInstanceState) val toolbar = view.findViewById(R.id.toolbar) setupToolbar(toolbar) + setupViews(view) mapView = view.findViewById(R.id.mapView) .setup(savedInstanceState) { mapboxMap -> this.mapboxMap = mapboxMap mapboxMap.setStyle(Style.MAPBOX_STREETS) { style -> enableLocationPlugin(style) - hoveringMarker = ImageView(requireContext()) .apply { setImageResource(R.drawable.mapbox_marker_icon_default) @@ -133,56 +193,72 @@ class FakeLocationFragment : layoutParams = params } mapView.addView(hoveringMarker) - initDroppedMarker(style) + hoveringMarker?.visibility = View.GONE // Keep hovering marker hidden by default + + mapboxMap.addOnCameraMoveStartedListener { + // Show marker when user starts to move across the map. + if (mapView.isEnabled) { + hoveringMarker?.visibility = View.VISIBLE + } + isCameraMoved = true + } + mapboxMap.addOnCameraMoveListener { - mutableLatLongFlow.value = mapboxMap.cameraPosition.target + if (mapView.isEnabled) { + viewModel.submitAction( + FakeLocationFeature.Action.UpdateLocationAction( + mapboxMap.cameraPosition.target + ) + ) + } } - mapboxMap.addOnCameraIdleListener { Log.d("Mapview", "camera move ended") } + // Bind click listeners once map is ready. + bindClickListeners() } } - bindClickListeners(view) } - private fun bindClickListeners(fragmentView: View) { - val latEditText = - fragmentView.findViewById(R.id.edittext_latitude).editText - val longEditText = - fragmentView.findViewById(R.id.edittext_longitude).editText - - fragmentView.let { - it.findViewById(R.id.radio_use_real_location) - .setOnClickListener { radioButton -> - toggleLocationType(radioButton) - } - it.findViewById(R.id.radio_use_random_location) - .setOnClickListener { radioButton -> - toggleLocationType(radioButton) - } - it.findViewById(R.id.radio_use_specific_location) - .setOnClickListener { radioButton -> - toggleLocationType(radioButton) - } - it.findViewById