Merge pull request #8144 from vector-im/feature/mna/user-location-in-loc-sharing

[Location sharing] Show own location in map views
This commit is contained in:
Maxime NATUREL 2023-02-20 16:34:37 +01:00 committed by GitHub
commit 6bd150d4cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 655 additions and 177 deletions

1
changelog.d/8110.feature Normal file
View File

@ -0,0 +1 @@
[Location sharing] Show own location in map views

View File

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 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 androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R
fun Fragment.showUserLocationNotAvailableErrorDialog(onConfirmListener: () -> Unit) {
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.location_not_available_dialog_title)
.setMessage(R.string.location_not_available_dialog_content)
.setPositiveButton(R.string.ok) { _, _ ->
onConfirmListener()
}
.setCancelable(false)
.show()
}

View File

@ -176,14 +176,7 @@ class LocationSharingFragment :
}
private fun handleLocationNotAvailableError() {
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.location_not_available_dialog_title)
.setMessage(R.string.location_not_available_dialog_content)
.setPositiveButton(R.string.ok) { _, _ ->
locationSharingNavigator.quit()
}
.setCancelable(false)
.show()
showUserLocationNotAvailableErrorDialog { locationSharingNavigator.quit() }
}
private fun handleLiveLocationSharingNotEnoughPermission() {

View File

@ -47,7 +47,7 @@ data class LocationSharingViewState(
fun LocationSharingViewState.toMapState() = MapState(
zoomOnlyOnce = true,
userLocationData = lastKnownUserLocation,
pinLocationData = lastKnownUserLocation,
pinId = DEFAULT_PIN_ID,
pinDrawable = null,
// show the map pin only when target location and user location are not equal

View File

@ -66,6 +66,8 @@ class LocationTracker @Inject constructor(
@VisibleForTesting
var hasLocationFromGPSProvider = false
private var isStarted = false
private var isStarting = false
private var firstLocationHandled = false
private val _locations = MutableSharedFlow<Location>(replay = 1)
@ -90,43 +92,48 @@ class LocationTracker @Inject constructor(
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
Timber.d("start()")
if (!isStarting && !isStarted) {
isStarting = true
Timber.d("start()")
if (locationManager == null) {
Timber.v("LocationManager is not available")
onNoLocationProviderAvailable()
return
}
if (locationManager == null) {
Timber.v("LocationManager is not available")
onNoLocationProviderAvailable()
return
}
val providers = locationManager.allProviders
val providers = locationManager.allProviders
if (providers.isEmpty()) {
Timber.v("There is no location provider available")
onNoLocationProviderAvailable()
} else {
// Take GPS first
providers.sortedByDescending(::getProviderPriority)
.mapNotNull { provider ->
Timber.d("track location using $provider")
if (providers.isEmpty()) {
Timber.v("There is no location provider available")
onNoLocationProviderAvailable()
} else {
// Take GPS first
providers.sortedByDescending(::getProviderPriority)
.mapNotNull { provider ->
Timber.d("track location using $provider")
locationManager.requestLocationUpdates(
provider,
minDurationToUpdateLocationMillis,
MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
this
)
locationManager.requestLocationUpdates(
provider,
minDurationToUpdateLocationMillis,
MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
this
)
locationManager.getLastKnownLocation(provider)
}
.maxByOrNull { location -> location.time }
?.let { latestKnownLocation ->
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.d("lastKnownLocation: $latestKnownLocation")
} else {
Timber.d("lastKnownLocation: ${latestKnownLocation.provider}")
locationManager.getLastKnownLocation(provider)
}
notifyLocation(latestKnownLocation)
}
.maxByOrNull { location -> location.time }
?.let { latestKnownLocation ->
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.d("lastKnownLocation: $latestKnownLocation")
} else {
Timber.d("lastKnownLocation: ${latestKnownLocation.provider}")
}
notifyLocation(latestKnownLocation)
}
}
isStarted = true
isStarting = false
}
}
@ -148,6 +155,8 @@ class LocationTracker @Inject constructor(
callbacks.clear()
hasLocationFromGPSProvider = false
hasLocationFromFusedProvider = false
isStarting = false
isStarted = false
}
/**

View File

@ -21,9 +21,10 @@ import androidx.annotation.Px
data class MapState(
val zoomOnlyOnce: Boolean,
val userLocationData: LocationData? = null,
val pinLocationData: LocationData? = null,
val pinId: String,
val pinDrawable: Drawable? = null,
val showPin: Boolean = true,
@Px val logoMarginBottom: Int = 0
val userLocationData: LocationData? = null,
@Px val logoMarginBottom: Int = 0,
)

View File

@ -18,6 +18,7 @@ package im.vector.app.features.location
import android.content.Context
import android.content.res.TypedArray
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.Gravity
import android.widget.ImageView
@ -38,6 +39,8 @@ import im.vector.app.R
import im.vector.app.core.utils.DimensionConverter
import timber.log.Timber
private const val USER_PIN_ID = "user-pin-id"
class MapTilerMapView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@ -101,9 +104,11 @@ class MapTilerMapView @JvmOverloads constructor(
private fun initMapStyle(map: MapboxMap, url: String) {
map.setStyle(url) { style ->
val symbolManager = SymbolManager(this, map, style)
symbolManager.iconAllowOverlap = true
mapRefs = MapRefs(
map,
SymbolManager(this, map, style),
symbolManager,
style
)
pendingState?.let { render(it) }
@ -166,29 +171,43 @@ class MapTilerMapView @JvmOverloads constructor(
}
val pinDrawable = state.pinDrawable ?: userLocationDrawable
pinDrawable?.let { drawable ->
if (!safeMapRefs.style.isFullyLoaded ||
safeMapRefs.style.getImage(state.pinId) == null) {
safeMapRefs.style.addImage(state.pinId, drawable.toBitmap())
}
}
addImageToMapStyle(pinDrawable, state.pinId, safeMapRefs)
state.userLocationData?.let { locationData ->
safeMapRefs.symbolManager.deleteAll()
state.pinLocationData?.let { locationData ->
if (!initZoomDone || !state.zoomOnlyOnce) {
zoomToLocation(locationData)
initZoomDone = true
}
safeMapRefs.symbolManager.deleteAll()
if (pinDrawable != null && state.showPin) {
safeMapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
.withIconImage(state.pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
createSymbol(locationData, state.pinId, safeMapRefs)
}
}
state.userLocationData?.let { locationData ->
addImageToMapStyle(userLocationDrawable, USER_PIN_ID, safeMapRefs)
if (userLocationDrawable != null) {
createSymbol(locationData, USER_PIN_ID, safeMapRefs)
}
}
}
private fun addImageToMapStyle(image: Drawable?, imageId: String, mapRefs: MapRefs) {
image?.let { drawable ->
if (!mapRefs.style.isFullyLoaded || mapRefs.style.getImage(imageId) == null) {
mapRefs.style.addImage(imageId, drawable.toBitmap())
}
}
}
private fun createSymbol(locationData: LocationData, imageId: String, mapRefs: MapRefs) {
mapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
.withIconImage(imageId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
}
fun zoomToLocation(locationData: LocationData) {

View File

@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction {
data class RemoveMapSymbol(val key: String) : LiveLocationMapAction()
object StopSharing : LiveLocationMapAction()
object ShowMapLoadingError : LiveLocationMapAction()
object ZoomToUserLocation : LiveLocationMapAction()
}

View File

@ -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
}

View File

@ -24,6 +24,8 @@ import android.view.ViewGroup
import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import androidx.core.view.marginTop
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
@ -46,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
@ -58,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.
*/
@ -68,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()
@ -75,7 +86,7 @@ class LiveLocationMapViewFragment :
private var mapView: MapView? = null
private var symbolManager: SymbolManager? = null
private var mapStyle: Style? = null
private val pendingLiveLocations = mutableListOf<UserLiveLocationViewState>()
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
@ -88,6 +99,7 @@ class LiveLocationMapViewFragment :
super.onViewCreated(view, savedInstanceState)
observeViewEvents()
setupMap()
initLocateButton()
views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true)
@ -105,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()
@ -139,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)
@ -189,9 +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.showLocateUserButton)
}
private fun updateUserListBottomSheet(userLocations: List<UserLiveLocationViewState>) {
@ -236,7 +285,24 @@ class LiveLocationMapViewFragment :
}
}
private fun updateMap(userLiveLocations: List<UserLiveLocationViewState>) {
private fun updateLocateButton(showLocateButton: Boolean) {
views.liveLocationMapLocateButton.isVisible = showLocateButton
adjustCompassButton()
}
private fun adjustCompassButton() {
val locateButton = views.liveLocationMapLocateButton
locateButton.post {
val marginTop = locateButton.height + locateButton.marginTop + locateButton.marginBottom
val marginRight = locateButton.context.resources.getDimensionPixelOffset(R.dimen.location_sharing_compass_button_margin_horizontal)
mapboxMap?.get()?.uiSettings?.setCompassMargins(0, marginTop, marginRight, 0)
}
}
private fun updateMap(
userLiveLocations: List<UserLiveLocationViewState>,
userLocation: LocationData?,
) {
symbolManager?.let { sManager ->
val latLngBoundsBuilder = LatLngBounds.Builder()
userLiveLocations.forEach { userLocation ->
@ -249,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
@ -279,17 +377,11 @@ class LiveLocationMapViewFragment :
}
private fun removeOutdatedSymbols(userLiveLocations: List<UserLiveLocationViewState>, 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)
}
}
@ -304,27 +396,35 @@ class LiveLocationMapViewFragment :
}
}
private fun postponeUpdateOfMap(userLiveLocations: List<UserLiveLocationViewState>) {
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 ->

View File

@ -23,19 +23,27 @@ 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.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
class LiveLocationMapViewModel @AssistedInject constructor(
@Assisted private val initialState: LiveLocationMapViewState,
private val session: Session,
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase,
private val locationSharingServiceConnection: LocationSharingServiceConnection,
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase,
) : VectorViewModel<LiveLocationMapViewState, LiveLocationMapAction, LiveLocationMapViewEvents>(initialState), LocationSharingServiceConnection.Callback {
private val locationTracker: LocationTracker,
) :
VectorViewModel<LiveLocationMapViewState, LiveLocationMapAction, LiveLocationMapViewEvents>(initialState),
LocationSharingServiceConnection.Callback,
LocationTracker.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LiveLocationMapViewModel, LiveLocationMapViewState> {
@ -46,12 +54,37 @@ class LiveLocationMapViewModel @AssistedInject constructor(
init {
getListOfUserLiveLocationUseCase.execute(initialState.roomId)
.onEach { setState { copy(userLocations = it) } }
.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()
}
@ -62,6 +95,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
is LiveLocationMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
LiveLocationMapAction.StopSharing -> handleStopSharing()
LiveLocationMapAction.ShowMapLoadingError -> handleShowMapLoadingError()
LiveLocationMapAction.ZoomToUserLocation -> handleZoomToUserLocation()
}
}
@ -83,7 +117,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))
}
}
}
@ -92,6 +126,18 @@ class LiveLocationMapViewModel @AssistedInject constructor(
setState { copy(loadingMapHasFailed = true) }
}
private fun handleZoomToUserLocation() = withState { state ->
if (!state.isLoadingUserLocation) {
setState {
copy(isLoadingUserLocation = true)
}
viewModelScope.launch(session.coroutineDispatchers.main) {
locationTracker.start()
locationTracker.requestLastKnownLocation()
}
}
}
override fun onLocationServiceRunning(roomIds: Set<String>) {
// NOOP
}
@ -101,6 +147,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)
}
}

View File

@ -29,6 +29,9 @@ data class LiveLocationMapViewState(
*/
val mapSymbolIds: Map<String, Long> = emptyMap(),
val loadingMapHasFailed: Boolean = false,
val showLocateUserButton: Boolean = false,
val isLoadingUserLocation: Boolean = false,
val lastKnownUserLocation: LocationData? = null,
) : MavericksState {
constructor(liveLocationMapViewArgs: LiveLocationMapViewArgs) : this(
roomId = liveLocationMapViewArgs.roomId

View File

@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationPreviewAction : VectorViewModelAction {
object ShowMapLoadingError : LocationPreviewAction()
object ZoomToUserLocation : LocationPreviewAction()
}

View File

@ -31,18 +31,22 @@ import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider
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.FragmentLocationPreviewBinding
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.DEFAULT_PIN_ID
import im.vector.app.features.location.LocationSharingArgs
import im.vector.app.features.location.MapState
import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.showUserLocationNotAvailableErrorDialog
import java.lang.ref.WeakReference
import javax.inject.Inject
/*
* TODO Move locationPinProvider to a ViewModel
/**
* Screen displaying the expanded map of a static location share.
*/
@AndroidEntryPoint
class LocationPreviewFragment :
@ -50,7 +54,6 @@ class LocationPreviewFragment :
VectorMenuProvider {
@Inject lateinit var urlMapProvider: UrlMapProvider
@Inject lateinit var locationPinProvider: LocationPinProvider
private val args: LocationSharingArgs by args()
@ -76,8 +79,29 @@ class LocationPreviewFragment :
lifecycleScope.launchWhenCreated {
views.mapView.initialize(urlMapProvider.getMapUrl())
loadPinDrawable()
}
observeViewEvents()
initLocateButton()
}
private fun observeViewEvents() {
viewModel.observeViewEvents {
when (it) {
LocationPreviewViewEvents.UserLocationNotAvailableError -> handleUserLocationNotAvailableError()
is LocationPreviewViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(it)
}
}
}
private fun handleUserLocationNotAvailableError() {
showUserLocationNotAvailableErrorDialog {
// do nothing
}
}
private fun handleZoomToUserLocationEvent(event: LocationPreviewViewEvents.ZoomToUserLocation) {
views.mapView.zoomToLocation(event.userLocation)
}
override fun onDestroyView() {
@ -124,6 +148,24 @@ class LocationPreviewFragment :
override fun invalidate() = withState(viewModel) { state ->
views.mapPreviewLoadingError.isVisible = state.loadingMapHasFailed
if (state.isLoadingUserLocation) {
showLoadingDialog()
} else {
dismissLoadingDialog()
}
updateMap(state)
}
private fun updateMap(viewState: LocationPreviewViewState) {
views.mapView.render(
MapState(
zoomOnlyOnce = true,
pinLocationData = viewState.pinLocationData,
pinId = viewState.pinUserId ?: DEFAULT_PIN_ID,
pinDrawable = viewState.pinDrawable,
userLocationData = viewState.lastKnownUserLocation,
)
)
}
override fun getMenuRes() = R.menu.menu_location_preview
@ -143,21 +185,23 @@ class LocationPreviewFragment :
openLocation(requireActivity(), location.latitude, location.longitude)
}
private fun loadPinDrawable() {
val location = args.initialLocationData ?: return
val userId = args.locationOwnerId
locationPinProvider.create(userId) { pinDrawable ->
lifecycleScope.launchWhenResumed {
views.mapView.render(
MapState(
zoomOnlyOnce = true,
userLocationData = location,
pinId = args.locationOwnerId ?: DEFAULT_PIN_ID,
pinDrawable = pinDrawable
)
)
private fun initLocateButton() {
views.mapView.locateButton.setOnClickListener {
if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher)) {
zoomToUserLocation()
}
}
}
private fun zoomToUserLocation() {
viewModel.handle(LocationPreviewAction.ZoomToUserLocation)
}
private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
zoomToUserLocation()
} else if (deniedPermanently) {
activity?.onPermissionDeniedDialog(R.string.denied_permission_generic)
}
}
}

View File

@ -0,0 +1,25 @@
/*
* 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.preview
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.location.LocationData
sealed class LocationPreviewViewEvents : VectorViewEvents {
data class ZoomToUserLocation(val userLocation: LocationData) : LocationPreviewViewEvents()
object UserLocationNotAvailableError : LocationPreviewViewEvents()
}

View File

@ -22,12 +22,21 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationTracker
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
class LocationPreviewViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationPreviewViewState,
) : VectorViewModel<LocationPreviewViewState, LocationPreviewAction, EmptyViewEvents>(initialState) {
private val session: Session,
private val locationPinProvider: LocationPinProvider,
private val locationTracker: LocationTracker,
) : VectorViewModel<LocationPreviewViewState, LocationPreviewAction, LocationPreviewViewEvents>(initialState), LocationTracker.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> {
@ -36,13 +45,68 @@ class LocationPreviewViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> by hiltMavericksViewModelFactory()
init {
initPin(initialState.pinUserId)
initLocationTracking()
}
private fun initPin(userId: String?) {
locationPinProvider.create(userId) { pinDrawable ->
setState { copy(pinDrawable = pinDrawable) }
}
}
private fun initLocationTracking() {
locationTracker.addCallback(this)
locationTracker.locations
.onEach(::onLocationUpdate)
.launchIn(viewModelScope)
}
override fun onCleared() {
super.onCleared()
locationTracker.removeCallback(this)
}
override fun handle(action: LocationPreviewAction) {
when (action) {
LocationPreviewAction.ShowMapLoadingError -> handleShowMapLoadingError()
LocationPreviewAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
}
}
private fun handleShowMapLoadingError() {
setState { copy(loadingMapHasFailed = true) }
}
private fun handleZoomToUserLocationAction() = withState { state ->
if (!state.isLoadingUserLocation) {
setState {
copy(isLoadingUserLocation = true)
}
viewModelScope.launch(session.coroutineDispatchers.main) {
locationTracker.start()
locationTracker.requestLastKnownLocation()
}
}
}
override fun onNoLocationProviderAvailable() {
_viewEvents.post(LocationPreviewViewEvents.UserLocationNotAvailableError)
}
private fun onLocationUpdate(locationData: LocationData) = withState { state ->
val zoomToUserLocation = state.isLoadingUserLocation
setState {
copy(
lastKnownUserLocation = locationData,
isLoadingUserLocation = false,
)
}
if (zoomToUserLocation) {
_viewEvents.post(LocationPreviewViewEvents.ZoomToUserLocation(locationData))
}
}
}

View File

@ -16,8 +16,22 @@
package im.vector.app.features.location.preview
import android.graphics.drawable.Drawable
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingArgs
data class LocationPreviewViewState(
val loadingMapHasFailed: Boolean = false
) : MavericksState
val pinLocationData: LocationData? = null,
val pinUserId: String? = null,
val pinDrawable: Drawable? = null,
val loadingMapHasFailed: Boolean = false,
val isLoadingUserLocation: Boolean = false,
val lastKnownUserLocation: LocationData? = null,
) : MavericksState {
constructor(args: LocationSharingArgs) : this(
pinLocationData = args.initialLocationData,
pinUserId = args.locationOwnerId,
)
}

View File

@ -1,14 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<size
android:width="13dp"
android:height="13dp" />
<solid android:color="?colorPrimary" />
<stroke
android:width="2dp"
android:color="@color/palette_white" />
</shape>
</item>
</layer-list>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="28dp"
android:viewportWidth="20"
android:viewportHeight="28">
<path
android:pathData="M10,0.667C4.84,0.667 0.667,4.953 0.667,10.254C0.667,15.965 6.56,23.841 8.987,26.84C9.52,27.497 10.493,27.497 11.027,26.84C13.44,23.841 19.333,15.965 19.333,10.254C19.333,4.953 15.16,0.667 10,0.667ZM10,13.678C8.16,13.678 6.667,12.144 6.667,10.254C6.667,8.364 8.16,6.83 10,6.83C11.84,6.83 13.333,8.364 13.333,10.254C13.333,12.144 11.84,13.678 10,13.678Z"
android:fillColor="#0DBD8B"/>
</vector>

View File

@ -17,6 +17,20 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/liveLocationMapLocateButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_marginHorizontal="@dimen/location_sharing_locate_button_margin_horizontal"
android:layout_marginVertical="@dimen/location_sharing_locate_button_margin_vertical"
android:clickable="true"
android:contentDescription="@string/a11y_location_share_locate_button"
android:focusable="true"
android:src="@drawable/btn_locate"
android:visibility="gone"
tools:visibility="visible" />
<im.vector.app.features.location.MapLoadingErrorView
android:id="@+id/mapPreviewLoadingError"
android:layout_width="match_parent"

View File

@ -13,7 +13,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:mapbox_renderTextureMode="true"
app:showLocateButton="false" />
app:showLocateButton="true" />
<im.vector.app.features.location.MapLoadingErrorView
android:id="@+id/mapPreviewLoadingError"

View File

@ -20,13 +20,14 @@ import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
import im.vector.app.test.fakes.FakeLocationSharingServiceConnection
import im.vector.app.test.fakes.FakeLocationTracker
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.test
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Rule
import org.junit.Test
@ -41,16 +42,20 @@ class LiveLocationMapViewModelTest {
private val args = LiveLocationMapViewArgs(roomId = A_ROOM_ID)
private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
private val locationServiceConnection = FakeLocationSharingServiceConnection()
private val stopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>()
private val fakeSession = FakeSession()
private val fakeGetListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
private val fakeLocationSharingServiceConnection = FakeLocationSharingServiceConnection()
private val fakeStopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>()
private val fakeLocationTracker = FakeLocationTracker()
private fun createViewModel(): LiveLocationMapViewModel {
return LiveLocationMapViewModel(
LiveLocationMapViewState(args),
getListOfUserLiveLocationUseCase,
locationServiceConnection.instance,
stopLiveLocationShareUseCase
session = fakeSession,
getListOfUserLiveLocationUseCase = fakeGetListOfUserLiveLocationUseCase,
locationSharingServiceConnection = fakeLocationSharingServiceConnection.instance,
stopLiveLocationShareUseCase = fakeStopLiveLocationShareUseCase,
locationTracker = fakeLocationTracker.instance,
)
}
@ -60,30 +65,94 @@ class LiveLocationMapViewModelTest {
}
@Test
fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest {
val userLocations = listOf(
UserLiveLocationViewState(
MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""),
pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123,
locationTimestampMillis = 123,
showStopSharingButton = false
)
)
locationServiceConnection.givenBind()
every { getListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
fun `given the viewModel has been initialized then viewState contains user locations list and location tracker is setup`() {
// Given
val userLocations = listOf(givenAUserLiveLocationViewState(userId = "@userId1:matrix.org"))
fakeLocationSharingServiceConnection.givenBind()
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
// When
val viewModel = createViewModel()
viewModel
.test()
val viewModelTest = viewModel.test()
// Then
viewModelTest
.assertState(
LiveLocationMapViewState(args).copy(
userLocations = userLocations
userLocations = userLocations,
showLocateUserButton = true,
)
).finish()
fakeLocationSharingServiceConnection.verifyBind(viewModel)
fakeLocationTracker.verifyAddCallback(viewModel)
}
@Test
fun `given the viewModel when it is cleared cleanUp are done`() {
// Given
fakeLocationSharingServiceConnection.givenBind()
fakeLocationSharingServiceConnection.givenUnbind()
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(emptyList())
val viewModel = createViewModel()
// When
viewModel.onCleared()
// Then
fakeLocationSharingServiceConnection.verifyUnbind(viewModel)
fakeLocationTracker.verifyRemoveCallback(viewModel)
}
@Test
fun `given current user shares their live location then locate button should not be shown`() {
// Given
val userLocations = listOf(givenAUserLiveLocationViewState(userId = fakeSession.myUserId))
fakeLocationSharingServiceConnection.givenBind()
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
val viewModel = createViewModel()
// When
val viewModelTest = viewModel.test()
// Then
viewModelTest
.assertState(
LiveLocationMapViewState(args).copy(
userLocations = userLocations,
showLocateUserButton = false,
)
)
.finish()
locationServiceConnection.verifyBind(viewModel)
}
@Test
fun `given current user does not share their live location then locate button should be shown`() {
// Given
val userLocations = listOf(givenAUserLiveLocationViewState(userId = "@userId1:matrix.org"))
fakeLocationSharingServiceConnection.givenBind()
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
val viewModel = createViewModel()
// When
val viewModelTest = viewModel.test()
// Then
viewModelTest
.assertState(
LiveLocationMapViewState(args).copy(
userLocations = userLocations,
showLocateUserButton = true,
)
)
.finish()
}
private fun givenAUserLiveLocationViewState(userId: String) = UserLiveLocationViewState(
MatrixItem.UserItem(id = userId, displayName = "User 1", avatarUrl = ""),
pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123,
locationTimestampMillis = 123,
showStopSharingButton = false
)
}

View File

@ -17,10 +17,8 @@
package im.vector.app.test.fakes
import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection
import io.mockk.every
import io.mockk.just
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
class FakeLocationSharingServiceConnection {
@ -28,10 +26,18 @@ class FakeLocationSharingServiceConnection {
val instance = mockk<LocationSharingServiceConnection>()
fun givenBind() {
every { instance.bind(any()) } just runs
justRun { instance.bind(any()) }
}
fun verifyBind(callback: LocationSharingServiceConnection.Callback) {
verify { instance.bind(callback) }
}
fun givenUnbind() {
justRun { instance.unbind(any()) }
}
fun verifyUnbind(callback: LocationSharingServiceConnection.Callback) {
verify { instance.unbind(callback) }
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright (c) 2023 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.test.fakes
import im.vector.app.features.location.LocationTracker
import io.mockk.mockk
import io.mockk.verify
class FakeLocationTracker {
val instance: LocationTracker = mockk(relaxed = true)
fun verifyAddCallback(callback: LocationTracker.Callback) {
verify { instance.addCallback(callback) }
}
fun verifyRemoveCallback(callback: LocationTracker.Callback) {
verify { instance.removeCallback(callback) }
}
}