From b5af6f5a0fc31f5295e6321dfe1979e6b29a46a7 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL <46314705+mnaturel@users.noreply.github.com> Date: Fri, 17 Feb 2023 14:57:06 +0100 Subject: [PATCH] Render user location pin on the live location sharing map preview --- .../lib/core/utils/timer/CountUpTimer.kt | 1 + .../live/map/LiveLocationMapAction.kt | 1 + .../live/map/LiveLocationMapViewEvents.kt | 5 +- .../live/map/LiveLocationMapViewFragment.kt | 181 +++++++++++++----- .../live/map/LiveLocationMapViewModel.kt | 58 +++++- .../live/map/LiveLocationMapViewState.kt | 4 +- .../preview/LocationPreviewViewModel.kt | 12 +- .../preview/LocationPreviewViewState.kt | 2 +- 8 files changed, 202 insertions(+), 62 deletions(-) diff --git a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt index 17af1c3190..369e96682a 100644 --- a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt +++ b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt @@ -33,6 +33,7 @@ class CountUpTimer( private val lastTime: AtomicLong = AtomicLong(clock.epochMillis()) private val elapsedTime: AtomicLong = AtomicLong(0) + // To ensure that the regular tick value is an exact multiple of `intervalInMs` private val specialRound = SpecialRound(intervalInMs) diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt index 295d6b5d41..4bb86c8f53 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt @@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction { data class RemoveMapSymbol(val key: String) : LiveLocationMapAction() object StopSharing : LiveLocationMapAction() object ShowMapLoadingError : LiveLocationMapAction() + object ZoomToUserLocation : LiveLocationMapAction() } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt index 2c4f34dce0..89a300a2e2 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt @@ -17,7 +17,10 @@ package im.vector.app.features.location.live.map import im.vector.app.core.platform.VectorViewEvents +import im.vector.app.features.location.LocationData sealed interface LiveLocationMapViewEvents : VectorViewEvents { - data class Error(val error: Throwable) : LiveLocationMapViewEvents + data class LiveLocationError(val error: Throwable) : LiveLocationMapViewEvents + data class ZoomToUserLocation(val userLocation: LocationData) : LiveLocationMapViewEvents + object UserLocationNotAvailableError : LiveLocationMapViewEvents } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt index 96a8915611..3c02d5d87d 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt @@ -48,11 +48,17 @@ import im.vector.app.core.extensions.addChildFragment import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.utils.DimensionConverter +import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedDialog import im.vector.app.core.utils.openLocation +import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentLiveLocationMapViewBinding import im.vector.app.features.location.LocationData import im.vector.app.features.location.UrlMapProvider +import im.vector.app.features.location.showUserLocationNotAvailableErrorDialog import im.vector.app.features.location.zoomToBounds import im.vector.app.features.location.zoomToLocation import kotlinx.coroutines.launch @@ -60,6 +66,8 @@ import timber.log.Timber import java.lang.ref.WeakReference import javax.inject.Inject +private const val USER_LOCATION_PIN_ID = "user-location-pin-id" + /** * Screen showing a map with all the current users sharing their live location in a room. */ @@ -70,6 +78,7 @@ class LiveLocationMapViewFragment : @Inject lateinit var urlMapProvider: UrlMapProvider @Inject lateinit var bottomSheetController: LiveLocationBottomSheetController @Inject lateinit var dimensionConverter: DimensionConverter + @Inject lateinit var drawableProvider: DrawableProvider private val viewModel: LiveLocationMapViewModel by fragmentViewModel() @@ -77,7 +86,7 @@ class LiveLocationMapViewFragment : private var mapView: MapView? = null private var symbolManager: SymbolManager? = null private var mapStyle: Style? = null - private val pendingLiveLocations = mutableListOf() + private val userLocationDrawable by lazy { drawableProvider.getDrawable(R.drawable.ic_location_user) } private var isMapFirstUpdate = true private var onSymbolClickListener: OnSymbolClickListener? = null private var mapLoadingErrorListener: MapView.OnDidFailLoadingMapListener? = null @@ -90,6 +99,7 @@ class LiveLocationMapViewFragment : super.onViewCreated(view, savedInstanceState) observeViewEvents() setupMap() + initLocateButton() views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true) @@ -107,11 +117,23 @@ class LiveLocationMapViewFragment : private fun observeViewEvents() { viewModel.observeViewEvents { viewEvent -> when (viewEvent) { - is LiveLocationMapViewEvents.Error -> displayErrorDialog(viewEvent.error) + is LiveLocationMapViewEvents.LiveLocationError -> displayErrorDialog(viewEvent.error) + is LiveLocationMapViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(viewEvent) + LiveLocationMapViewEvents.UserLocationNotAvailableError -> handleUserLocationNotAvailableError() } } } + private fun handleZoomToUserLocationEvent(event: LiveLocationMapViewEvents.ZoomToUserLocation) { + mapboxMap?.get().zoomToLocation(event.userLocation) + } + + private fun handleUserLocationNotAvailableError() { + showUserLocationNotAvailableErrorDialog { + // do nothing + } + } + override fun onDestroyView() { onSymbolClickListener?.let { symbolManager?.removeClickListener(it) } symbolManager?.onDestroy() @@ -141,14 +163,33 @@ class LiveLocationMapViewFragment : true }.also { addClickListener(it) } } - pendingLiveLocations - .takeUnless { it.isEmpty() } - ?.let { updateMap(it) } + // force refresh of the map using the last viewState + invalidate() } } } } + private fun initLocateButton() { + views.liveLocationMapLocateButton.setOnClickListener { + if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher)) { + zoomToUserLocation() + } + } + } + + private fun zoomToUserLocation() { + viewModel.handle(LiveLocationMapAction.ZoomToUserLocation) + } + + private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + zoomToUserLocation() + } else if (deniedPermanently) { + activity?.onPermissionDeniedDialog(R.string.denied_permission_generic) + } + } + private fun listenMapLoadingError(mapView: MapView) { mapLoadingErrorListener = MapView.OnDidFailLoadingMapListener { viewModel.handle(LiveLocationMapAction.ShowMapLoadingError) @@ -191,10 +232,15 @@ class LiveLocationMapViewFragment : views.mapPreviewLoadingError.isVisible = true } else { views.mapPreviewLoadingError.isGone = true - updateMap(viewState.userLocations) + updateMap(userLiveLocations = viewState.userLocations, userLocation = viewState.lastKnownUserLocation) + } + if (viewState.isLoadingUserLocation) { + showLoadingDialog() + } else { + dismissLoadingDialog() } updateUserListBottomSheet(viewState.userLocations) - updateLocateButton(showLocateButton = viewState.showLocateButton) + updateLocateButton(showLocateButton = viewState.showLocateUserButton) } private fun updateUserListBottomSheet(userLocations: List) { @@ -253,7 +299,10 @@ class LiveLocationMapViewFragment : } } - private fun updateMap(userLiveLocations: List) { + private fun updateMap( + userLiveLocations: List, + userLocation: LocationData?, + ) { symbolManager?.let { sManager -> val latLngBoundsBuilder = LatLngBounds.Builder() userLiveLocations.forEach { userLocation -> @@ -266,28 +315,60 @@ class LiveLocationMapViewFragment : removeOutdatedSymbols(userLiveLocations, sManager) updateMapZoomWhenNeeded(userLiveLocations, latLngBoundsBuilder) - } ?: postponeUpdateOfMap(userLiveLocations) - } - - private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) = withState(viewModel) { state -> - val symbolId = state.mapSymbolIds[userLocation.matrixItem.id] - - if (symbolId == null || symbolManager.annotations.get(symbolId) == null) { - createSymbol(userLocation, symbolManager) - } else { - updateSymbol(symbolId, userLocation, symbolManager) + if (userLocation == null) { + removeUserSymbol(sManager) + } else { + createOrUpdateUserSymbol(userLocation, sManager) + } } } - private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) { - addUserPinToMapStyle(userLocation.matrixItem.id, userLocation.pinDrawable) - val symbolOptions = buildSymbolOptions(userLocation) - val symbol = symbolManager.create(symbolOptions) - viewModel.handle(LiveLocationMapAction.AddMapSymbol(userLocation.matrixItem.id, symbol.id)) + private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) { + val pinId = userLocation.matrixItem.id + val pinDrawable = userLocation.pinDrawable + createOrUpdateSymbol(pinId, pinDrawable, userLocation.locationData, symbolManager) } - private fun updateSymbol(symbolId: Long, userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) { - val newLocation = LatLng(userLocation.locationData.latitude, userLocation.locationData.longitude) + private fun createOrUpdateUserSymbol(locationData: LocationData, symbolManager: SymbolManager) { + userLocationDrawable?.let { pinDrawable -> createOrUpdateSymbol(USER_LOCATION_PIN_ID, pinDrawable, locationData, symbolManager) } + } + + private fun removeUserSymbol(symbolManager: SymbolManager) = withState(viewModel) { state -> + val pinId = USER_LOCATION_PIN_ID + state.mapSymbolIds[pinId]?.let { symbolId -> + removeSymbol(pinId, symbolId, symbolManager) + } + } + + private fun createOrUpdateSymbol( + pinId: String, + pinDrawable: Drawable, + locationData: LocationData, + symbolManager: SymbolManager + ) = withState(viewModel) { state -> + val symbolId = state.mapSymbolIds[pinId] + + if (symbolId == null || symbolManager.annotations.get(symbolId) == null) { + createSymbol(pinId, pinDrawable, locationData, symbolManager) + } else { + updateSymbol(symbolId, locationData, symbolManager) + } + } + + private fun createSymbol( + pinId: String, + pinDrawable: Drawable, + locationData: LocationData, + symbolManager: SymbolManager + ) { + addPinToMapStyle(pinId, pinDrawable) + val symbolOptions = buildSymbolOptions(locationData, pinId) + val symbol = symbolManager.create(symbolOptions) + viewModel.handle(LiveLocationMapAction.AddMapSymbol(pinId, symbol.id)) + } + + private fun updateSymbol(symbolId: Long, locationData: LocationData, symbolManager: SymbolManager) { + val newLocation = LatLng(locationData.latitude, locationData.longitude) val symbol = symbolManager.annotations.get(symbolId) symbol?.let { it.latLng = newLocation @@ -296,17 +377,11 @@ class LiveLocationMapViewFragment : } private fun removeOutdatedSymbols(userLiveLocations: List, symbolManager: SymbolManager) = withState(viewModel) { state -> - val userIdsToRemove = state.mapSymbolIds.keys.subtract(userLiveLocations.map { it.matrixItem.id }.toSet()) - userIdsToRemove.forEach { userId -> - removeUserPinFromMapStyle(userId) - viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(userId)) - - state.mapSymbolIds[userId]?.let { symbolId -> - Timber.d("trying to delete symbol with id: $symbolId") - symbolManager.annotations.get(symbolId)?.let { - symbolManager.delete(it) - } - } + val pinIdsToKeep = userLiveLocations.map { it.matrixItem.id } + USER_LOCATION_PIN_ID + val pinIdsToRemove = state.mapSymbolIds.keys.subtract(pinIdsToKeep.toSet()) + pinIdsToRemove.forEach { pinId -> + val symbolId = state.mapSymbolIds[pinId] + removeSymbol(pinId, symbolId, symbolManager) } } @@ -321,27 +396,35 @@ class LiveLocationMapViewFragment : } } - private fun postponeUpdateOfMap(userLiveLocations: List) { - pendingLiveLocations.clear() - pendingLiveLocations.addAll(userLiveLocations) - } - - private fun addUserPinToMapStyle(userId: String, userPinDrawable: Drawable) { + private fun addPinToMapStyle(pinId: String, pinDrawable: Drawable) { mapStyle?.let { style -> - if (style.getImage(userId) == null) { - style.addImage(userId, userPinDrawable.toBitmap()) + if (style.getImage(pinId) == null) { + style.addImage(pinId, pinDrawable.toBitmap()) } } } - private fun removeUserPinFromMapStyle(userId: String) { - mapStyle?.removeImage(userId) + private fun removeSymbol(pinId: String, symbolId: Long?, symbolManager: SymbolManager) { + removeUserPinFromMapStyle(pinId) + + symbolId?.let { id -> + Timber.d("trying to delete symbol with id: $id") + symbolManager.annotations.get(id)?.let { + symbolManager.delete(it) + } + } + + viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(pinId)) } - private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) = + private fun removeUserPinFromMapStyle(pinId: String) { + mapStyle?.removeImage(pinId) + } + + private fun buildSymbolOptions(locationData: LocationData, pinId: String) = SymbolOptions() - .withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude)) - .withIconImage(userLiveLocation.matrixItem.id) + .withLatLng(LatLng(locationData.latitude, locationData.longitude)) + .withIconImage(pinId) .withIconAnchor(Property.ICON_ANCHOR_BOTTOM) private fun handleBottomSheetUserSelected(userId: String) = withState(viewModel) { state -> diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt index eb9de0aa74..dab2b43ca0 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt @@ -23,8 +23,11 @@ import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.location.LocationData +import im.vector.app.features.location.LocationTracker import im.vector.app.features.location.live.StopLiveLocationShareUseCase import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -37,7 +40,11 @@ class LiveLocationMapViewModel @AssistedInject constructor( getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase, private val locationSharingServiceConnection: LocationSharingServiceConnection, private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase, -) : VectorViewModel(initialState), LocationSharingServiceConnection.Callback { + private val locationTracker: LocationTracker, +) : + VectorViewModel(initialState), + LocationSharingServiceConnection.Callback, + LocationTracker.Callback { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -48,12 +55,37 @@ class LiveLocationMapViewModel @AssistedInject constructor( init { getListOfUserLiveLocationUseCase.execute(initialState.roomId) - .onEach { setState { copy(userLocations = it, showLocateButton = it.none { it.matrixItem.id == session.myUserId }) } } + .onEach { setState { copy(userLocations = it, showLocateUserButton = it.none { it.matrixItem.id == session.myUserId }) } } .launchIn(viewModelScope) locationSharingServiceConnection.bind(this) + initLocationTracking() + } + + private fun initLocationTracking() { + locationTracker.addCallback(this) + locationTracker.locations + .onEach(::onLocationUpdate) + .launchIn(viewModelScope) + } + + private fun onLocationUpdate(locationData: LocationData) = withState { state -> + val zoomToUserLocation = state.isLoadingUserLocation + val showLocateButton = state.showLocateUserButton + + setState { + copy( + lastKnownUserLocation = if (showLocateButton) locationData else null, + isLoadingUserLocation = false, + ) + } + + if (zoomToUserLocation) { + _viewEvents.post(LiveLocationMapViewEvents.ZoomToUserLocation(locationData)) + } } override fun onCleared() { + locationTracker.removeCallback(this) locationSharingServiceConnection.unbind(this) super.onCleared() } @@ -64,6 +96,7 @@ class LiveLocationMapViewModel @AssistedInject constructor( is LiveLocationMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action) LiveLocationMapAction.StopSharing -> handleStopSharing() LiveLocationMapAction.ShowMapLoadingError -> handleShowMapLoadingError() + LiveLocationMapAction.ZoomToUserLocation -> handleZoomToUserLocation() } } @@ -85,7 +118,7 @@ class LiveLocationMapViewModel @AssistedInject constructor( viewModelScope.launch { val result = stopLiveLocationShareUseCase.execute(initialState.roomId) if (result is UpdateLiveLocationShareResult.Failure) { - _viewEvents.post(LiveLocationMapViewEvents.Error(result.error)) + _viewEvents.post(LiveLocationMapViewEvents.LiveLocationError(result.error)) } } } @@ -94,6 +127,19 @@ class LiveLocationMapViewModel @AssistedInject constructor( setState { copy(loadingMapHasFailed = true) } } + // TODO add unit tests + private fun handleZoomToUserLocation() = withState { state -> + if (!state.isLoadingUserLocation) { + setState { + copy(isLoadingUserLocation = true) + } + viewModelScope.launch(Dispatchers.Main) { + locationTracker.start() + locationTracker.requestLastKnownLocation() + } + } + } + override fun onLocationServiceRunning(roomIds: Set) { // NOOP } @@ -103,6 +149,10 @@ class LiveLocationMapViewModel @AssistedInject constructor( } override fun onLocationServiceError(error: Throwable) { - _viewEvents.post(LiveLocationMapViewEvents.Error(error)) + _viewEvents.post(LiveLocationMapViewEvents.LiveLocationError(error)) + } + + override fun onNoLocationProviderAvailable() { + _viewEvents.post(LiveLocationMapViewEvents.UserLocationNotAvailableError) } } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt index 29447bb9ba..74b0023a08 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt @@ -29,7 +29,9 @@ data class LiveLocationMapViewState( */ val mapSymbolIds: Map = emptyMap(), val loadingMapHasFailed: Boolean = false, - val showLocateButton: Boolean = false, + val showLocateUserButton: Boolean = false, + val isLoadingUserLocation: Boolean = false, + val lastKnownUserLocation: LocationData? = null, ) : MavericksState { constructor(liveLocationMapViewArgs: LiveLocationMapViewArgs) : this( roomId = liveLocationMapViewArgs.roomId diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt index 10ce406629..3822d9e9ee 100644 --- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt @@ -94,12 +94,8 @@ class LocationPreviewViewModel @AssistedInject constructor( _viewEvents.post(LocationPreviewViewEvents.UserLocationNotAvailableError) } - private fun onLocationUpdate(locationData: LocationData) { - withState { state -> - if (state.isLoadingUserLocation) { - _viewEvents.post(LocationPreviewViewEvents.ZoomToUserLocation(locationData)) - } - } + private fun onLocationUpdate(locationData: LocationData) = withState { state -> + val zoomToUserLocation = state.isLoadingUserLocation setState { copy( @@ -107,5 +103,9 @@ class LocationPreviewViewModel @AssistedInject constructor( isLoadingUserLocation = false, ) } + + if (zoomToUserLocation) { + _viewEvents.post(LocationPreviewViewEvents.ZoomToUserLocation(locationData)) + } } } diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt index 15fa911607..23f8d4d7dc 100644 --- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt +++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt @@ -30,7 +30,7 @@ data class LocationPreviewViewState( val lastKnownUserLocation: LocationData? = null, ) : MavericksState { - constructor(args: LocationSharingArgs): this( + constructor(args: LocationSharingArgs) : this( pinLocationData = args.initialLocationData, pinUserId = args.locationOwnerId, )