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() { private fun handleLocationNotAvailableError() {
MaterialAlertDialogBuilder(requireActivity()) showUserLocationNotAvailableErrorDialog { locationSharingNavigator.quit() }
.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()
} }
private fun handleLiveLocationSharingNotEnoughPermission() { private fun handleLiveLocationSharingNotEnoughPermission() {

View File

@ -47,7 +47,7 @@ data class LocationSharingViewState(
fun LocationSharingViewState.toMapState() = MapState( fun LocationSharingViewState.toMapState() = MapState(
zoomOnlyOnce = true, zoomOnlyOnce = true,
userLocationData = lastKnownUserLocation, pinLocationData = lastKnownUserLocation,
pinId = DEFAULT_PIN_ID, pinId = DEFAULT_PIN_ID,
pinDrawable = null, pinDrawable = null,
// show the map pin only when target location and user location are not equal // 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 @VisibleForTesting
var hasLocationFromGPSProvider = false var hasLocationFromGPSProvider = false
private var isStarted = false
private var isStarting = false
private var firstLocationHandled = false private var firstLocationHandled = false
private val _locations = MutableSharedFlow<Location>(replay = 1) private val _locations = MutableSharedFlow<Location>(replay = 1)
@ -90,6 +92,8 @@ class LocationTracker @Inject constructor(
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() { fun start() {
if (!isStarting && !isStarted) {
isStarting = true
Timber.d("start()") Timber.d("start()")
if (locationManager == null) { if (locationManager == null) {
@ -128,6 +132,9 @@ class LocationTracker @Inject constructor(
notifyLocation(latestKnownLocation) notifyLocation(latestKnownLocation)
} }
} }
isStarted = true
isStarting = false
}
} }
/** /**
@ -148,6 +155,8 @@ class LocationTracker @Inject constructor(
callbacks.clear() callbacks.clear()
hasLocationFromGPSProvider = false hasLocationFromGPSProvider = false
hasLocationFromFusedProvider = false hasLocationFromFusedProvider = false
isStarting = false
isStarted = false
} }
/** /**

View File

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

View File

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

View File

@ -17,7 +17,10 @@
package im.vector.app.features.location.live.map package im.vector.app.features.location.live.map
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.location.LocationData
sealed interface LiveLocationMapViewEvents : VectorViewEvents { 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.graphics.drawable.toBitmap
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import androidx.core.view.marginTop
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState 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.cleanup
import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment 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.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.openLocation
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentLiveLocationMapViewBinding import im.vector.app.databinding.FragmentLiveLocationMapViewBinding
import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationData
import im.vector.app.features.location.UrlMapProvider 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.zoomToBounds
import im.vector.app.features.location.zoomToLocation import im.vector.app.features.location.zoomToLocation
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -58,6 +66,8 @@ import timber.log.Timber
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import javax.inject.Inject 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. * 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 urlMapProvider: UrlMapProvider
@Inject lateinit var bottomSheetController: LiveLocationBottomSheetController @Inject lateinit var bottomSheetController: LiveLocationBottomSheetController
@Inject lateinit var dimensionConverter: DimensionConverter @Inject lateinit var dimensionConverter: DimensionConverter
@Inject lateinit var drawableProvider: DrawableProvider
private val viewModel: LiveLocationMapViewModel by fragmentViewModel() private val viewModel: LiveLocationMapViewModel by fragmentViewModel()
@ -75,7 +86,7 @@ class LiveLocationMapViewFragment :
private var mapView: MapView? = null private var mapView: MapView? = null
private var symbolManager: SymbolManager? = null private var symbolManager: SymbolManager? = null
private var mapStyle: Style? = 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 isMapFirstUpdate = true
private var onSymbolClickListener: OnSymbolClickListener? = null private var onSymbolClickListener: OnSymbolClickListener? = null
private var mapLoadingErrorListener: MapView.OnDidFailLoadingMapListener? = null private var mapLoadingErrorListener: MapView.OnDidFailLoadingMapListener? = null
@ -88,6 +99,7 @@ class LiveLocationMapViewFragment :
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
observeViewEvents() observeViewEvents()
setupMap() setupMap()
initLocateButton()
views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true) views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true)
@ -105,11 +117,23 @@ class LiveLocationMapViewFragment :
private fun observeViewEvents() { private fun observeViewEvents() {
viewModel.observeViewEvents { viewEvent -> viewModel.observeViewEvents { viewEvent ->
when (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() { override fun onDestroyView() {
onSymbolClickListener?.let { symbolManager?.removeClickListener(it) } onSymbolClickListener?.let { symbolManager?.removeClickListener(it) }
symbolManager?.onDestroy() symbolManager?.onDestroy()
@ -139,14 +163,33 @@ class LiveLocationMapViewFragment :
true true
}.also { addClickListener(it) } }.also { addClickListener(it) }
} }
pendingLiveLocations // force refresh of the map using the last viewState
.takeUnless { it.isEmpty() } invalidate()
?.let { updateMap(it) }
} }
} }
} }
} }
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) { private fun listenMapLoadingError(mapView: MapView) {
mapLoadingErrorListener = MapView.OnDidFailLoadingMapListener { mapLoadingErrorListener = MapView.OnDidFailLoadingMapListener {
viewModel.handle(LiveLocationMapAction.ShowMapLoadingError) viewModel.handle(LiveLocationMapAction.ShowMapLoadingError)
@ -189,9 +232,15 @@ class LiveLocationMapViewFragment :
views.mapPreviewLoadingError.isVisible = true views.mapPreviewLoadingError.isVisible = true
} else { } else {
views.mapPreviewLoadingError.isGone = true views.mapPreviewLoadingError.isGone = true
updateMap(viewState.userLocations) updateMap(userLiveLocations = viewState.userLocations, userLocation = viewState.lastKnownUserLocation)
}
if (viewState.isLoadingUserLocation) {
showLoadingDialog()
} else {
dismissLoadingDialog()
} }
updateUserListBottomSheet(viewState.userLocations) updateUserListBottomSheet(viewState.userLocations)
updateLocateButton(showLocateButton = viewState.showLocateUserButton)
} }
private fun updateUserListBottomSheet(userLocations: List<UserLiveLocationViewState>) { 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 -> symbolManager?.let { sManager ->
val latLngBoundsBuilder = LatLngBounds.Builder() val latLngBoundsBuilder = LatLngBounds.Builder()
userLiveLocations.forEach { userLocation -> userLiveLocations.forEach { userLocation ->
@ -249,28 +315,60 @@ class LiveLocationMapViewFragment :
removeOutdatedSymbols(userLiveLocations, sManager) removeOutdatedSymbols(userLiveLocations, sManager)
updateMapZoomWhenNeeded(userLiveLocations, latLngBoundsBuilder) updateMapZoomWhenNeeded(userLiveLocations, latLngBoundsBuilder)
} ?: postponeUpdateOfMap(userLiveLocations) if (userLocation == null) {
removeUserSymbol(sManager)
} else {
createOrUpdateUserSymbol(userLocation, sManager)
}
}
} }
private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) = withState(viewModel) { state -> private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
val symbolId = state.mapSymbolIds[userLocation.matrixItem.id] val pinId = userLocation.matrixItem.id
val pinDrawable = userLocation.pinDrawable
createOrUpdateSymbol(pinId, pinDrawable, userLocation.locationData, symbolManager)
}
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) { if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
createSymbol(userLocation, symbolManager) createSymbol(pinId, pinDrawable, locationData, symbolManager)
} else { } else {
updateSymbol(symbolId, userLocation, symbolManager) updateSymbol(symbolId, locationData, symbolManager)
} }
} }
private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) { private fun createSymbol(
addUserPinToMapStyle(userLocation.matrixItem.id, userLocation.pinDrawable) pinId: String,
val symbolOptions = buildSymbolOptions(userLocation) pinDrawable: Drawable,
locationData: LocationData,
symbolManager: SymbolManager
) {
addPinToMapStyle(pinId, pinDrawable)
val symbolOptions = buildSymbolOptions(locationData, pinId)
val symbol = symbolManager.create(symbolOptions) val symbol = symbolManager.create(symbolOptions)
viewModel.handle(LiveLocationMapAction.AddMapSymbol(userLocation.matrixItem.id, symbol.id)) viewModel.handle(LiveLocationMapAction.AddMapSymbol(pinId, symbol.id))
} }
private fun updateSymbol(symbolId: Long, userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) { private fun updateSymbol(symbolId: Long, locationData: LocationData, symbolManager: SymbolManager) {
val newLocation = LatLng(userLocation.locationData.latitude, userLocation.locationData.longitude) val newLocation = LatLng(locationData.latitude, locationData.longitude)
val symbol = symbolManager.annotations.get(symbolId) val symbol = symbolManager.annotations.get(symbolId)
symbol?.let { symbol?.let {
it.latLng = newLocation it.latLng = newLocation
@ -279,17 +377,11 @@ class LiveLocationMapViewFragment :
} }
private fun removeOutdatedSymbols(userLiveLocations: List<UserLiveLocationViewState>, symbolManager: SymbolManager) = withState(viewModel) { state -> private fun removeOutdatedSymbols(userLiveLocations: List<UserLiveLocationViewState>, symbolManager: SymbolManager) = withState(viewModel) { state ->
val userIdsToRemove = state.mapSymbolIds.keys.subtract(userLiveLocations.map { it.matrixItem.id }.toSet()) val pinIdsToKeep = userLiveLocations.map { it.matrixItem.id } + USER_LOCATION_PIN_ID
userIdsToRemove.forEach { userId -> val pinIdsToRemove = state.mapSymbolIds.keys.subtract(pinIdsToKeep.toSet())
removeUserPinFromMapStyle(userId) pinIdsToRemove.forEach { pinId ->
viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(userId)) val symbolId = state.mapSymbolIds[pinId]
removeSymbol(pinId, symbolId, symbolManager)
state.mapSymbolIds[userId]?.let { symbolId ->
Timber.d("trying to delete symbol with id: $symbolId")
symbolManager.annotations.get(symbolId)?.let {
symbolManager.delete(it)
}
}
} }
} }
@ -304,27 +396,35 @@ class LiveLocationMapViewFragment :
} }
} }
private fun postponeUpdateOfMap(userLiveLocations: List<UserLiveLocationViewState>) { private fun addPinToMapStyle(pinId: String, pinDrawable: Drawable) {
pendingLiveLocations.clear()
pendingLiveLocations.addAll(userLiveLocations)
}
private fun addUserPinToMapStyle(userId: String, userPinDrawable: Drawable) {
mapStyle?.let { style -> mapStyle?.let { style ->
if (style.getImage(userId) == null) { if (style.getImage(pinId) == null) {
style.addImage(userId, userPinDrawable.toBitmap()) style.addImage(pinId, pinDrawable.toBitmap())
} }
} }
} }
private fun removeUserPinFromMapStyle(userId: String) { private fun removeSymbol(pinId: String, symbolId: Long?, symbolManager: SymbolManager) {
mapStyle?.removeImage(userId) removeUserPinFromMapStyle(pinId)
symbolId?.let { id ->
Timber.d("trying to delete symbol with id: $id")
symbolManager.annotations.get(id)?.let {
symbolManager.delete(it)
}
} }
private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) = viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(pinId))
}
private fun removeUserPinFromMapStyle(pinId: String) {
mapStyle?.removeImage(pinId)
}
private fun buildSymbolOptions(locationData: LocationData, pinId: String) =
SymbolOptions() SymbolOptions()
.withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude)) .withLatLng(LatLng(locationData.latitude, locationData.longitude))
.withIconImage(userLiveLocation.matrixItem.id) .withIconImage(pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM) .withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
private fun handleBottomSheetUserSelected(userId: String) = withState(viewModel) { state -> 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.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel 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.StopLiveLocationShareUseCase
import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
class LiveLocationMapViewModel @AssistedInject constructor( class LiveLocationMapViewModel @AssistedInject constructor(
@Assisted private val initialState: LiveLocationMapViewState, @Assisted private val initialState: LiveLocationMapViewState,
private val session: Session,
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase, getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase,
private val locationSharingServiceConnection: LocationSharingServiceConnection, private val locationSharingServiceConnection: LocationSharingServiceConnection,
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase, 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 @AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LiveLocationMapViewModel, LiveLocationMapViewState> { interface Factory : MavericksAssistedViewModelFactory<LiveLocationMapViewModel, LiveLocationMapViewState> {
@ -46,12 +54,37 @@ class LiveLocationMapViewModel @AssistedInject constructor(
init { init {
getListOfUserLiveLocationUseCase.execute(initialState.roomId) getListOfUserLiveLocationUseCase.execute(initialState.roomId)
.onEach { setState { copy(userLocations = it) } } .onEach { setState { copy(userLocations = it, showLocateUserButton = it.none { it.matrixItem.id == session.myUserId }) } }
.launchIn(viewModelScope) .launchIn(viewModelScope)
locationSharingServiceConnection.bind(this) 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() { override fun onCleared() {
locationTracker.removeCallback(this)
locationSharingServiceConnection.unbind(this) locationSharingServiceConnection.unbind(this)
super.onCleared() super.onCleared()
} }
@ -62,6 +95,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
is LiveLocationMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action) is LiveLocationMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
LiveLocationMapAction.StopSharing -> handleStopSharing() LiveLocationMapAction.StopSharing -> handleStopSharing()
LiveLocationMapAction.ShowMapLoadingError -> handleShowMapLoadingError() LiveLocationMapAction.ShowMapLoadingError -> handleShowMapLoadingError()
LiveLocationMapAction.ZoomToUserLocation -> handleZoomToUserLocation()
} }
} }
@ -83,7 +117,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
viewModelScope.launch { viewModelScope.launch {
val result = stopLiveLocationShareUseCase.execute(initialState.roomId) val result = stopLiveLocationShareUseCase.execute(initialState.roomId)
if (result is UpdateLiveLocationShareResult.Failure) { 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) } 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>) { override fun onLocationServiceRunning(roomIds: Set<String>) {
// NOOP // NOOP
} }
@ -101,6 +147,10 @@ class LiveLocationMapViewModel @AssistedInject constructor(
} }
override fun onLocationServiceError(error: Throwable) { 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 mapSymbolIds: Map<String, Long> = emptyMap(),
val loadingMapHasFailed: Boolean = false, val loadingMapHasFailed: Boolean = false,
val showLocateUserButton: Boolean = false,
val isLoadingUserLocation: Boolean = false,
val lastKnownUserLocation: LocationData? = null,
) : MavericksState { ) : MavericksState {
constructor(liveLocationMapViewArgs: LiveLocationMapViewArgs) : this( constructor(liveLocationMapViewArgs: LiveLocationMapViewArgs) : this(
roomId = liveLocationMapViewArgs.roomId roomId = liveLocationMapViewArgs.roomId

View File

@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationPreviewAction : VectorViewModelAction { sealed class LocationPreviewAction : VectorViewModelAction {
object ShowMapLoadingError : LocationPreviewAction() 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.R
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider 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.openLocation
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentLocationPreviewBinding 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.DEFAULT_PIN_ID
import im.vector.app.features.location.LocationSharingArgs import im.vector.app.features.location.LocationSharingArgs
import im.vector.app.features.location.MapState import im.vector.app.features.location.MapState
import im.vector.app.features.location.UrlMapProvider import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.showUserLocationNotAvailableErrorDialog
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import javax.inject.Inject import javax.inject.Inject
/* /**
* TODO Move locationPinProvider to a ViewModel * Screen displaying the expanded map of a static location share.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class LocationPreviewFragment : class LocationPreviewFragment :
@ -50,7 +54,6 @@ class LocationPreviewFragment :
VectorMenuProvider { VectorMenuProvider {
@Inject lateinit var urlMapProvider: UrlMapProvider @Inject lateinit var urlMapProvider: UrlMapProvider
@Inject lateinit var locationPinProvider: LocationPinProvider
private val args: LocationSharingArgs by args() private val args: LocationSharingArgs by args()
@ -76,8 +79,29 @@ class LocationPreviewFragment :
lifecycleScope.launchWhenCreated { lifecycleScope.launchWhenCreated {
views.mapView.initialize(urlMapProvider.getMapUrl()) 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() { override fun onDestroyView() {
@ -124,6 +148,24 @@ class LocationPreviewFragment :
override fun invalidate() = withState(viewModel) { state -> override fun invalidate() = withState(viewModel) { state ->
views.mapPreviewLoadingError.isVisible = state.loadingMapHasFailed 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 override fun getMenuRes() = R.menu.menu_location_preview
@ -143,21 +185,23 @@ class LocationPreviewFragment :
openLocation(requireActivity(), location.latitude, location.longitude) openLocation(requireActivity(), location.latitude, location.longitude)
} }
private fun loadPinDrawable() { private fun initLocateButton() {
val location = args.initialLocationData ?: return views.mapView.locateButton.setOnClickListener {
val userId = args.locationOwnerId if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher)) {
zoomToUserLocation()
}
}
}
locationPinProvider.create(userId) { pinDrawable -> private fun zoomToUserLocation() {
lifecycleScope.launchWhenResumed { viewModel.handle(LocationPreviewAction.ZoomToUserLocation)
views.mapView.render( }
MapState(
zoomOnlyOnce = true, private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
userLocationData = location, if (allGranted) {
pinId = args.locationOwnerId ?: DEFAULT_PIN_ID, zoomToUserLocation()
pinDrawable = pinDrawable } 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 dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory 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.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( class LocationPreviewViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationPreviewViewState, @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 @AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> { interface Factory : MavericksAssistedViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> {
@ -36,13 +45,68 @@ class LocationPreviewViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> by hiltMavericksViewModelFactory() 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) { override fun handle(action: LocationPreviewAction) {
when (action) { when (action) {
LocationPreviewAction.ShowMapLoadingError -> handleShowMapLoadingError() LocationPreviewAction.ShowMapLoadingError -> handleShowMapLoadingError()
LocationPreviewAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
} }
} }
private fun handleShowMapLoadingError() { private fun handleShowMapLoadingError() {
setState { copy(loadingMapHasFailed = true) } 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 package im.vector.app.features.location.preview
import android.graphics.drawable.Drawable
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingArgs
data class LocationPreviewViewState( data class LocationPreviewViewState(
val loadingMapHasFailed: Boolean = false val pinLocationData: LocationData? = null,
) : MavericksState 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"?> <vector xmlns:android="http://schemas.android.com/apk/res/android"
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> android:width="20dp"
<item> android:height="28dp"
<shape android:shape="oval"> android:viewportWidth="20"
<size android:viewportHeight="28">
android:width="13dp" <path
android:height="13dp" /> 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"
<solid android:color="?colorPrimary" /> android:fillColor="#0DBD8B"/>
<stroke </vector>
android:width="2dp"
android:color="@color/palette_white" />
</shape>
</item>
</layer-list>

View File

@ -17,6 +17,20 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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 <im.vector.app.features.location.MapLoadingErrorView
android:id="@+id/mapPreviewLoadingError" android:id="@+id/mapPreviewLoadingError"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -13,7 +13,7 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:mapbox_renderTextureMode="true" app:mapbox_renderTextureMode="true"
app:showLocateButton="false" /> app:showLocateButton="true" />
<im.vector.app.features.location.MapLoadingErrorView <im.vector.app.features.location.MapLoadingErrorView
android:id="@+id/mapPreviewLoadingError" 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.LocationData
import im.vector.app.features.location.live.StopLiveLocationShareUseCase import im.vector.app.features.location.live.StopLiveLocationShareUseCase
import im.vector.app.test.fakes.FakeLocationSharingServiceConnection 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 im.vector.app.test.test
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.unmockkAll import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -41,16 +42,20 @@ class LiveLocationMapViewModelTest {
private val args = LiveLocationMapViewArgs(roomId = A_ROOM_ID) private val args = LiveLocationMapViewArgs(roomId = A_ROOM_ID)
private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>() private val fakeSession = FakeSession()
private val locationServiceConnection = FakeLocationSharingServiceConnection() private val fakeGetListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
private val stopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>() private val fakeLocationSharingServiceConnection = FakeLocationSharingServiceConnection()
private val fakeStopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>()
private val fakeLocationTracker = FakeLocationTracker()
private fun createViewModel(): LiveLocationMapViewModel { private fun createViewModel(): LiveLocationMapViewModel {
return LiveLocationMapViewModel( return LiveLocationMapViewModel(
LiveLocationMapViewState(args), LiveLocationMapViewState(args),
getListOfUserLiveLocationUseCase, session = fakeSession,
locationServiceConnection.instance, getListOfUserLiveLocationUseCase = fakeGetListOfUserLiveLocationUseCase,
stopLiveLocationShareUseCase locationSharingServiceConnection = fakeLocationSharingServiceConnection.instance,
stopLiveLocationShareUseCase = fakeStopLiveLocationShareUseCase,
locationTracker = fakeLocationTracker.instance,
) )
} }
@ -60,30 +65,94 @@ class LiveLocationMapViewModelTest {
} }
@Test @Test
fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest { fun `given the viewModel has been initialized then viewState contains user locations list and location tracker is setup`() {
val userLocations = listOf( // Given
UserLiveLocationViewState( val userLocations = listOf(givenAUserLiveLocationViewState(userId = "@userId1:matrix.org"))
MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""), fakeLocationSharingServiceConnection.givenBind()
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
// Then
viewModelTest
.assertState(
LiveLocationMapViewState(args).copy(
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()
}
@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(), pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null), locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123, endOfLiveTimestampMillis = 123,
locationTimestampMillis = 123, locationTimestampMillis = 123,
showStopSharingButton = false showStopSharingButton = false
) )
)
locationServiceConnection.givenBind()
every { getListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
val viewModel = createViewModel()
viewModel
.test()
.assertState(
LiveLocationMapViewState(args).copy(
userLocations = userLocations
)
)
.finish()
locationServiceConnection.verifyBind(viewModel)
}
} }

View File

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