diff --git a/changelog.d/8110.feature b/changelog.d/8110.feature new file mode 100644 index 0000000000..6ac7a98c3c --- /dev/null +++ b/changelog.d/8110.feature @@ -0,0 +1 @@ +[Location sharing] Show own location in map views diff --git a/vector/src/main/java/im/vector/app/features/location/LocationDialog.kt b/vector/src/main/java/im/vector/app/features/location/LocationDialog.kt new file mode 100644 index 0000000000..81ce75e57d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationDialog.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt index 779818b3d6..0fdf9d04cd 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt @@ -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() { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt index c7a2349afa..e11bfbf16e 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt index c617277f3f..f78b5e4311 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt @@ -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(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 } /** diff --git a/vector/src/main/java/im/vector/app/features/location/MapState.kt b/vector/src/main/java/im/vector/app/features/location/MapState.kt index c4325291a8..2224317b02 100644 --- a/vector/src/main/java/im/vector/app/features/location/MapState.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapState.kt @@ -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, ) diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt index b001621bf4..d7e3463a5c 100644 --- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt @@ -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) { diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt index 295d6b5d41..4bb86c8f53 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt @@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction { data class RemoveMapSymbol(val key: String) : LiveLocationMapAction() object StopSharing : LiveLocationMapAction() object ShowMapLoadingError : LiveLocationMapAction() + object ZoomToUserLocation : LiveLocationMapAction() } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt index 2c4f34dce0..89a300a2e2 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt @@ -17,7 +17,10 @@ package im.vector.app.features.location.live.map import im.vector.app.core.platform.VectorViewEvents +import im.vector.app.features.location.LocationData sealed interface LiveLocationMapViewEvents : VectorViewEvents { - data class Error(val error: Throwable) : LiveLocationMapViewEvents + data class LiveLocationError(val error: Throwable) : LiveLocationMapViewEvents + data class ZoomToUserLocation(val userLocation: LocationData) : LiveLocationMapViewEvents + object UserLocationNotAvailableError : LiveLocationMapViewEvents } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt index 942021dd64..3c02d5d87d 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt @@ -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() + 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) { @@ -236,7 +285,24 @@ class LiveLocationMapViewFragment : } } - private fun updateMap(userLiveLocations: List) { + 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, + 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, 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) { - pendingLiveLocations.clear() - pendingLiveLocations.addAll(userLiveLocations) - } - - private fun addUserPinToMapStyle(userId: String, userPinDrawable: Drawable) { + private fun addPinToMapStyle(pinId: String, pinDrawable: Drawable) { mapStyle?.let { style -> - if (style.getImage(userId) == null) { - style.addImage(userId, userPinDrawable.toBitmap()) + if (style.getImage(pinId) == null) { + style.addImage(pinId, pinDrawable.toBitmap()) } } } - private fun removeUserPinFromMapStyle(userId: String) { - mapStyle?.removeImage(userId) + private fun removeSymbol(pinId: String, symbolId: Long?, symbolManager: SymbolManager) { + removeUserPinFromMapStyle(pinId) + + symbolId?.let { id -> + Timber.d("trying to delete symbol with id: $id") + symbolManager.annotations.get(id)?.let { + symbolManager.delete(it) + } + } + + viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(pinId)) } - private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) = + private fun removeUserPinFromMapStyle(pinId: String) { + mapStyle?.removeImage(pinId) + } + + private fun buildSymbolOptions(locationData: LocationData, pinId: String) = SymbolOptions() - .withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude)) - .withIconImage(userLiveLocation.matrixItem.id) + .withLatLng(LatLng(locationData.latitude, locationData.longitude)) + .withIconImage(pinId) .withIconAnchor(Property.ICON_ANCHOR_BOTTOM) private fun handleBottomSheetUserSelected(userId: String) = withState(viewModel) { state -> diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt index 33c584ff85..15e41470e0 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt @@ -23,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(initialState), LocationSharingServiceConnection.Callback { + private val locationTracker: LocationTracker, +) : + VectorViewModel(initialState), + LocationSharingServiceConnection.Callback, + LocationTracker.Callback { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -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) { // 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) } } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt index ddd1cd2369..74b0023a08 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt @@ -29,6 +29,9 @@ data class LiveLocationMapViewState( */ val mapSymbolIds: Map = 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 diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt index 38f6952f67..094c2206fa 100644 --- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt +++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt @@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction sealed class LocationPreviewAction : VectorViewModelAction { object ShowMapLoadingError : LocationPreviewAction() + object ZoomToUserLocation : LocationPreviewAction() } diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt index 082cee02f0..1d816ddc83 100644 --- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt @@ -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) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewEvents.kt new file mode 100644 index 0000000000..605c240d06 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewEvents.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt index f0698249ce..a1544ac2af 100644 --- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt @@ -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(initialState) { + private val session: Session, + private val locationPinProvider: LocationPinProvider, + private val locationTracker: LocationTracker, +) : VectorViewModel(initialState), LocationTracker.Callback { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -36,13 +45,68 @@ class LocationPreviewViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory 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)) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt index 96e8316323..23f8d4d7dc 100644 --- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt +++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt @@ -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, + ) +} diff --git a/vector/src/main/res/drawable/ic_location_user.xml b/vector/src/main/res/drawable/ic_location_user.xml index dc6baca65e..cae89394f1 100644 --- a/vector/src/main/res/drawable/ic_location_user.xml +++ b/vector/src/main/res/drawable/ic_location_user.xml @@ -1,14 +1,9 @@ - - - - - - - - - - + + + diff --git a/vector/src/main/res/layout/fragment_live_location_map_view.xml b/vector/src/main/res/layout/fragment_live_location_map_view.xml index 369404ddbd..bef9264ce1 100644 --- a/vector/src/main/res/layout/fragment_live_location_map_view.xml +++ b/vector/src/main/res/layout/fragment_live_location_map_view.xml @@ -17,6 +17,20 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + app:showLocateButton="true" /> () - private val locationServiceConnection = FakeLocationSharingServiceConnection() - private val stopLiveLocationShareUseCase = mockk() + private val fakeSession = FakeSession() + private val fakeGetListOfUserLiveLocationUseCase = mockk() + private val fakeLocationSharingServiceConnection = FakeLocationSharingServiceConnection() + private val fakeStopLiveLocationShareUseCase = mockk() + 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 + ) } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingServiceConnection.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingServiceConnection.kt index a42ce78b15..ea836e644c 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingServiceConnection.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingServiceConnection.kt @@ -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() 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) } + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationTracker.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationTracker.kt new file mode 100644 index 0000000000..1fae85a3ff --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationTracker.kt @@ -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) } + } +}