Rework the location code - WIP

This commit is contained in:
Benoit Marty 2022-01-27 21:11:20 +01:00
parent e3242f0deb
commit e9b9406bf1
10 changed files with 247 additions and 159 deletions

View file

@ -28,6 +28,7 @@ import im.vector.app.core.glide.GlideApp
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -54,22 +55,36 @@ class LocationPinProvider @Inject constructor(
val size = dimensionConverter.dpToPx(44)
avatarRenderer.render(glideRequests, it, object : CustomTarget<Drawable>(size, size) {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!!
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, resource))
val horizontalInset = dimensionConverter.dpToPx(4)
val topInset = dimensionConverter.dpToPx(4)
val bottomInset = dimensionConverter.dpToPx(8)
layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset)
cache[userId] = layerDrawable
callback(layerDrawable)
Timber.d("## Location: onResourceReady")
val pinDrawable = createPinDrawable(resource)
cache[userId] = pinDrawable
callback(pinDrawable)
}
override fun onLoadCleared(placeholder: Drawable?) {
// Is it possible? Put placeholder instead?
// FIXME The doc says it has to be implemented and should free resources
Timber.d("## Location: onLoadCleared")
}
override fun onLoadFailed(errorDrawable: Drawable?) {
Timber.w("## Location: onLoadFailed")
errorDrawable ?: return
val pinDrawable = createPinDrawable(errorDrawable)
cache[userId] = pinDrawable
callback(pinDrawable)
}
})
}
}
private fun createPinDrawable(drawable: Drawable): Drawable {
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!!
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, drawable))
val horizontalInset = dimensionConverter.dpToPx(4)
val topInset = dimensionConverter.dpToPx(4)
val bottomInset = dimensionConverter.dpToPx(8)
layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset)
return layerDrawable
}
}

View file

@ -17,5 +17,5 @@
package im.vector.app.features.location
const val INITIAL_MAP_ZOOM = 15.0
const val MIN_TIME_MILLIS_TO_UPDATE_LOCATION = 1 * 60 * 1000L // every 1 minute
const val MIN_DISTANCE_METERS_TO_UPDATE_LOCATION = 10f
const val MIN_TIME_TO_UPDATE_LOCATION_MILLIS = 5 * 1_000L // every 5 seconds
const val MIN_DISTANCE_TO_UPDATE_LOCATION_METERS = 10f

View file

@ -22,19 +22,27 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.args
import com.mapbox.mapboxsdk.maps.MapView
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.openLocation
import im.vector.app.databinding.FragmentLocationPreviewBinding
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import java.lang.ref.WeakReference
import javax.inject.Inject
/**
* TODO Move locationPinProvider to a ViewModel
*/
class LocationPreviewFragment @Inject constructor(
private val locationPinProvider: LocationPinProvider
) : VectorBaseFragment<FragmentLocationPreviewBinding>() {
private val args: LocationSharingArgs by args()
// Keep a ref to handle properly the onDestroy callback
private var mapView: WeakReference<MapView>? = null
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationPreviewBinding {
return FragmentLocationPreviewBinding.inflate(layoutInflater, container, false)
}
@ -42,6 +50,8 @@ class LocationPreviewFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mapView = WeakReference(views.mapView)
views.mapView.onCreate(savedInstanceState)
views.mapView.initialize {
if (isAdded) {
onMapReady()
@ -49,16 +59,42 @@ class LocationPreviewFragment @Inject constructor(
}
}
override fun onResume() {
super.onResume()
views.mapView.onResume()
}
override fun onPause() {
views.mapView.onPause()
super.onPause()
}
override fun onLowMemory() {
views.mapView.onLowMemory()
super.onLowMemory()
}
override fun onStart() {
super.onStart()
views.mapView.onStart()
}
override fun onStop() {
views.mapView.onStop()
super.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
views.mapView.onSaveInstanceState(outState)
}
override fun onDestroy() {
mapView?.get()?.onDestroy()
mapView?.clear()
super.onDestroy()
}
override fun getMenuRes() = R.menu.menu_location_preview
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -85,7 +121,6 @@ class LocationPreviewFragment @Inject constructor(
locationPinProvider.create(userId) { pinDrawable ->
views.mapView.apply {
zoomToLocation(location.latitude, location.longitude, INITIAL_MAP_ZOOM)
deleteAllPins()
addPinToMap(userId, pinDrawable)
updatePinLocation(userId, location.latitude, location.longitude)
}

View file

@ -19,7 +19,5 @@ package im.vector.app.features.location
import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationSharingAction : VectorViewModelAction {
data class OnLocationUpdate(val locationData: LocationData) : LocationSharingAction()
object OnShareLocation : LocationSharingAction()
object OnLocationProviderIsNotAvailable : LocationSharingAction()
}

View file

@ -21,29 +21,29 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.mapbox.mapboxsdk.maps.MapView
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentLocationSharingBinding
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import org.matrix.android.sdk.api.session.Session
import java.lang.ref.WeakReference
import javax.inject.Inject
/**
* We should consider using SupportMapFragment for a out of the box lifecycle handling
*/
class LocationSharingFragment @Inject constructor(
private val locationTracker: LocationTracker,
private val session: Session,
private val locationPinProvider: LocationPinProvider
) : VectorBaseFragment<FragmentLocationSharingBinding>(), LocationTracker.Callback {
init {
locationTracker.callback = this
}
) : VectorBaseFragment<FragmentLocationSharingBinding>() {
private val viewModel: LocationSharingViewModel by fragmentViewModel()
private var lastZoomValue: Double = -1.0
// Keep a ref to handle properly the onDestroy callback
private var mapView: WeakReference<MapView>? = null
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationSharingBinding {
return FragmentLocationSharingBinding.inflate(inflater, container, false)
}
@ -51,11 +51,9 @@ class LocationSharingFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.mapView.initialize {
if (isAdded) {
onMapReady()
}
}
mapView = WeakReference(views.mapView)
views.mapView.onCreate(savedInstanceState)
views.mapView.initialize()
views.shareLocationContainer.debouncedClicks {
viewModel.handle(LocationSharingAction.OnShareLocation)
@ -63,54 +61,48 @@ class LocationSharingFragment @Inject constructor(
viewModel.observeViewEvents {
when (it) {
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
LocationSharingViewEvents.Close -> activity?.finish()
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
LocationSharingViewEvents.Close -> activity?.finish()
}.exhaustive
}
}
override fun onResume() {
super.onResume()
views.mapView.onResume()
}
override fun onPause() {
views.mapView.onPause()
super.onPause()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
views.mapView.onSaveInstanceState(outState)
}
override fun onStart() {
super.onStart()
views.mapView.onStart()
}
override fun onStop() {
views.mapView.onStop()
super.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
views.mapView.onLowMemory()
}
override fun onDestroy() {
locationTracker.stop()
mapView?.get()?.onDestroy()
mapView?.clear()
super.onDestroy()
}
private fun onMapReady() {
if (!isAdded) return
locationPinProvider.create(session.myUserId) {
views.mapView.addPinToMap(
pinId = USER_PIN_NAME,
image = it,
)
// All set, start location tracker
locationTracker.start()
}
}
override fun onLocationUpdate(locationData: LocationData) {
lastZoomValue = if (lastZoomValue == -1.0) INITIAL_MAP_ZOOM else views.mapView.getCurrentZoom() ?: INITIAL_MAP_ZOOM
views.mapView.zoomToLocation(locationData.latitude, locationData.longitude, lastZoomValue)
views.mapView.deleteAllPins()
views.mapView.updatePinLocation(USER_PIN_NAME, locationData.latitude, locationData.longitude)
viewModel.handle(LocationSharingAction.OnLocationUpdate(locationData))
}
override fun onLocationProviderIsNotAvailable() {
viewModel.handle(LocationSharingAction.OnLocationProviderIsNotAvailable)
}
private fun handleLocationNotAvailableError() {
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.location_not_available_dialog_title)
@ -122,6 +114,10 @@ class LocationSharingFragment @Inject constructor(
.show()
}
override fun invalidate() = withState(viewModel) { state ->
views.mapView.render(state)
}
companion object {
const val USER_PIN_NAME = "USER_PIN_NAME"
}

View file

@ -24,12 +24,15 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import org.matrix.android.sdk.api.session.Session
class LocationSharingViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationSharingViewState,
session: Session
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState) {
private val locationTracker: LocationTracker,
private val locationPinProvider: LocationPinProvider,
private val session: Session
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState), LocationTracker.Callback {
private val room = session.getRoom(initialState.roomId)!!
@ -38,14 +41,31 @@ class LocationSharingViewModel @AssistedInject constructor(
override fun create(initialState: LocationSharingViewState): LocationSharingViewModel
}
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory() {
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory()
init {
locationTracker.start(this)
createPin()
}
private fun createPin() {
locationPinProvider.create(session.myUserId) {
setState {
copy(
pinDrawable = it
)
}
}
}
override fun onCleared() {
super.onCleared()
locationTracker.stop()
}
override fun handle(action: LocationSharingAction) {
when (action) {
is LocationSharingAction.OnLocationUpdate -> handleLocationUpdate(action.locationData)
LocationSharingAction.OnShareLocation -> handleShareLocation()
LocationSharingAction.OnLocationProviderIsNotAvailable -> handleLocationProviderIsNotAvailable()
LocationSharingAction.OnShareLocation -> handleShareLocation()
}.exhaustive
}
@ -62,13 +82,13 @@ class LocationSharingViewModel @AssistedInject constructor(
}
}
private fun handleLocationUpdate(locationData: LocationData) {
override fun onLocationUpdate(locationData: LocationData) {
setState {
copy(lastKnownLocation = locationData)
}
}
private fun handleLocationProviderIsNotAvailable() {
override fun onLocationProviderIsNotAvailable() {
_viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError)
}
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.location
import android.graphics.drawable.Drawable
import androidx.annotation.StringRes
import com.airbnb.mvrx.MavericksState
import im.vector.app.R
@ -28,7 +29,8 @@ enum class LocationSharingMode(@StringRes val titleRes: Int) {
data class LocationSharingViewState(
val roomId: String,
val mode: LocationSharingMode,
val lastKnownLocation: LocationData? = null
val lastKnownLocation: LocationData? = null,
val pinDrawable: Drawable? = null
) : MavericksState {
constructor(locationSharingArgs: LocationSharingArgs) : this(

View file

@ -27,63 +27,73 @@ import timber.log.Timber
import javax.inject.Inject
class LocationTracker @Inject constructor(
private val context: Context
context: Context
) : LocationListenerCompat {
private val locationManager = context.getSystemService<LocationManager>()
interface Callback {
fun onLocationUpdate(locationData: LocationData)
fun onLocationProviderIsNotAvailable()
}
private var locationManager: LocationManager? = null
var callback: Callback? = null
private var callback: Callback? = null
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
val locationManager = context.getSystemService<LocationManager>()
fun start(callback: Callback?) {
Timber.d("## LocationTracker. start()")
this.callback = callback
locationManager?.let {
val isGpsEnabled = it.isProviderEnabled(LocationManager.GPS_PROVIDER)
val isNetworkEnabled = it.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
val provider = when {
isGpsEnabled -> LocationManager.GPS_PROVIDER
isNetworkEnabled -> LocationManager.NETWORK_PROVIDER
else -> {
callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. There is no location provider available")
return
}
}
// Send last known location without waiting location updates
it.getLastKnownLocation(provider)?.let { lastKnownLocation ->
callback?.onLocationUpdate(lastKnownLocation.toLocationData())
}
it.requestLocationUpdates(
provider,
MIN_TIME_MILLIS_TO_UPDATE_LOCATION,
MIN_DISTANCE_METERS_TO_UPDATE_LOCATION,
this
)
} ?: run {
if (locationManager == null) {
callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. LocationManager is not available")
return
}
val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
val isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
Timber.d("## LocationTracker. isGpsEnabled: $isGpsEnabled - isNetworkEnabled: $isNetworkEnabled")
val provider = when {
isGpsEnabled -> LocationManager.GPS_PROVIDER
isNetworkEnabled -> LocationManager.NETWORK_PROVIDER
else -> {
callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. There is no location provider available")
return
}
}
// Send last known location without waiting location updates
locationManager.getLastKnownLocation(provider)?.let { lastKnownLocation ->
Timber.d("## LocationTracker. lastKnownLocation")
callback?.onLocationUpdate(lastKnownLocation.toLocationData())
}
Timber.d("## LocationTracker. track location using $provider")
locationManager.requestLocationUpdates(
provider,
MIN_TIME_TO_UPDATE_LOCATION_MILLIS,
MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
this
)
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun stop() {
Timber.d("## LocationTracker. stop()")
locationManager?.removeUpdates(this)
callback = null
}
override fun onLocationChanged(location: Location) {
Timber.d("## LocationTracker. onLocationChanged")
callback?.onLocationUpdate(location.toLocationData())
}
override fun onProviderDisabled(provider: String) {
Timber.d("## LocationTracker. onProviderDisabled: $provider")
callback?.onLocationProviderIsNotAvailable()
}

View file

@ -28,34 +28,47 @@ import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property
import im.vector.app.BuildConfig
import timber.log.Timber
class MapTilerMapView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : MapView(context, attrs, defStyleAttr), VectorMapView {
) : MapView(context, attrs, defStyleAttr) {
private var map: MapboxMap? = null
private var symbolManager: SymbolManager? = null
private var style: Style? = null
private var pendingState: LocationSharingViewState? = null
override fun initialize(onMapReady: () -> Unit) {
data class MapRefs(
val map: MapboxMap,
val symbolManager: SymbolManager,
val style: Style
)
private var mapRefs: MapRefs? = null
private var initZoomDone = false
// TODO Kept only for the bottom sheet usage
fun initialize(onMapReady: () -> Unit) {
getMapAsync { map ->
map.setStyle(styleUrl) { style ->
this.symbolManager = SymbolManager(this, map, style)
this.map = map
this.style = style
mapRefs = MapRefs(
map,
SymbolManager(this, map, style),
style
)
onMapReady()
}
}
}
override fun addPinToMap(pinId: String, image: Drawable) {
style?.addImage(pinId, image)
// TODO Kept only for the bottom sheet usage
fun addPinToMap(pinId: String, image: Drawable) {
mapRefs?.style?.addImage(pinId, image)
}
override fun updatePinLocation(pinId: String, latitude: Double, longitude: Double) {
symbolManager?.create(
// TODO Kept only for the bottom sheet usage
fun updatePinLocation(pinId: String, latitude: Double, longitude: Double) {
mapRefs?.symbolManager?.create(
SymbolOptions()
.withLatLng(LatLng(latitude, longitude))
.withIconImage(pinId)
@ -63,28 +76,59 @@ class MapTilerMapView @JvmOverloads constructor(
)
}
override fun deleteAllPins() {
symbolManager?.deleteAll()
/**
* For location fragments
*/
fun initialize() {
Timber.d("## Location: initialize")
getMapAsync { map ->
map.setStyle(styleUrl) { style ->
mapRefs = MapRefs(
map,
SymbolManager(this, map, style),
style
)
pendingState?.let { render(it) }
pendingState = null
}
}
}
override fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double) {
map?.cameraPosition = CameraPosition.Builder()
fun render(data: LocationSharingViewState) {
val safeMapRefs = mapRefs ?: return Unit.also {
pendingState = data
}
data.pinDrawable?.let { pinDrawable ->
if (safeMapRefs.style.getImage(LocationSharingFragment.USER_PIN_NAME) == null) {
safeMapRefs.style.addImage(LocationSharingFragment.USER_PIN_NAME, pinDrawable)
}
}
data.lastKnownLocation?.let { locationData ->
if (!initZoomDone) {
zoomToLocation(locationData.latitude, locationData.longitude, INITIAL_MAP_ZOOM)
initZoomDone = true
}
safeMapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
.withIconImage(LocationSharingFragment.USER_PIN_NAME)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
}
}
fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double) {
Timber.d("## Location: zoomToLocation")
mapRefs?.map?.cameraPosition = CameraPosition.Builder()
.target(LatLng(latitude, longitude))
.zoom(zoom)
.build()
}
override fun getCurrentZoom(): Double? {
return map?.cameraPosition?.zoom
}
override fun onClick(callback: () -> Unit) {
map?.addOnMapClickListener {
callback()
true
}
}
companion object {
private const val styleUrl = "https://api.maptiler.com/maps/streets/style.json?key=${BuildConfig.mapTilerKey}"
}

View file

@ -1,32 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location
import android.graphics.drawable.Drawable
interface VectorMapView {
fun initialize(onMapReady: () -> Unit)
fun addPinToMap(pinId: String, image: Drawable)
fun updatePinLocation(pinId: String, latitude: Double, longitude: Double)
fun deleteAllPins()
fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double)
fun getCurrentZoom(): Double?
fun onClick(callback: () -> Unit)
}