diff --git a/changelog.d/6349.bugfix b/changelog.d/6349.bugfix new file mode 100644 index 0000000000..70718248a7 --- /dev/null +++ b/changelog.d/6349.bugfix @@ -0,0 +1 @@ +[Location sharing] Fix stop of a live not possible from another device diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt index 0f88f891cc..ada3dc85d7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt @@ -16,9 +16,11 @@ package org.matrix.android.sdk.api.session.room.location +import androidx.annotation.MainThread import androidx.lifecycle.LiveData import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.api.util.Optional /** * Manage all location sharing related features. @@ -59,5 +61,13 @@ interface LocationSharingService { /** * Returns a LiveData on the list of current running live location shares. */ + @MainThread fun getRunningLiveLocationShareSummaries(): LiveData> + + /** + * Returns a LiveData on the live location share summary with the given eventId. + * @param beaconInfoEventId event id of the initial beacon info state event + */ + @MainThread + fun getLiveLocationShareSummary(beaconInfoEventId: String): LiveData> } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt index 6bcd737474..d69f251f6f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt @@ -76,7 +76,7 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findActiveLiveIn realm: Realm, roomId: String, userId: String, - ignoredEventId: String + ignoredEventId: String, ): List { return LiveLocationShareAggregatedSummaryEntity .whereRoomId(realm, roomId = roomId) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt index 015c1cca0b..8e67142c52 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.location import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations import com.zhuinden.monarchy.Monarchy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -25,9 +26,12 @@ import org.matrix.android.sdk.api.session.room.location.LocationSharingService import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.database.mapper.LiveLocationShareAggregatedSummaryMapper import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.query.findRunningLiveInRoom +import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase internal class DefaultLocationSharingService @AssistedInject constructor( @@ -88,4 +92,15 @@ internal class DefaultLocationSharingService @AssistedInject constructor( liveLocationShareAggregatedSummaryMapper ) } + + override fun getLiveLocationShareSummary(beaconInfoEventId: String): LiveData> { + return Transformations.map( + monarchy.findAllMappedWithChanges( + { LiveLocationShareAggregatedSummaryEntity.where(it, roomId = roomId, eventId = beaconInfoEventId) }, + liveLocationShareAggregatedSummaryMapper + ) + ) { + it.firstOrNull().toOptional() + } + } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt index 30a9671733..4b556402d5 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt @@ -16,18 +16,27 @@ package org.matrix.android.sdk.internal.session.room.location +import androidx.arch.core.util.Function +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.After +import org.junit.Before import org.junit.Test import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.database.mapper.LiveLocationShareAggregatedSummaryMapper import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields @@ -46,7 +55,6 @@ private const val A_TIMEOUT = 15_000L @ExperimentalCoroutinesApi internal class DefaultLocationSharingServiceTest { - private val fakeRoomId = A_ROOM_ID private val fakeMonarchy = FakeMonarchy() private val sendStaticLocationTask = mockk() private val sendLiveLocationTask = mockk() @@ -55,7 +63,7 @@ internal class DefaultLocationSharingServiceTest { private val fakeLiveLocationShareAggregatedSummaryMapper = mockk() private val defaultLocationSharingService = DefaultLocationSharingService( - roomId = fakeRoomId, + roomId = A_ROOM_ID, monarchy = fakeMonarchy.instance, sendStaticLocationTask = sendStaticLocationTask, sendLiveLocationTask = sendLiveLocationTask, @@ -64,6 +72,11 @@ internal class DefaultLocationSharingServiceTest { liveLocationShareAggregatedSummaryMapper = fakeLiveLocationShareAggregatedSummaryMapper ) + @Before + fun setUp() { + mockkStatic("androidx.lifecycle.Transformations") + } + @After fun tearDown() { unmockkAll() @@ -154,7 +167,7 @@ internal class DefaultLocationSharingServiceTest { ) fakeMonarchy.givenWhere() - .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, fakeRoomId) + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, A_ROOM_ID) .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true) .givenIsNotEmpty(LiveLocationShareAggregatedSummaryEntityFields.USER_ID) .givenIsNotNull(LiveLocationShareAggregatedSummaryEntityFields.LAST_LOCATION_CONTENT) @@ -168,4 +181,38 @@ internal class DefaultLocationSharingServiceTest { result shouldBeEqualTo listOf(summary) } + + @Test + fun `given an event id when getting livedata on corresponding live summary then it is correctly computed`() { + val entity = LiveLocationShareAggregatedSummaryEntity() + val summary = LiveLocationShareAggregatedSummary( + userId = "", + isActive = true, + endOfLiveTimestampMillis = 123, + lastLocationDataContent = null + ) + + fakeMonarchy.givenWhere() + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, A_ROOM_ID) + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, AN_EVENT_ID) + val liveData = fakeMonarchy.givenFindAllMappedWithChangesReturns( + realmEntities = listOf(entity), + mappedResult = listOf(summary), + fakeLiveLocationShareAggregatedSummaryMapper + ) + val mapper = slot, Optional>>() + every { + Transformations.map( + liveData, + capture(mapper) + ) + } answers { + val value = secondArg, Optional>>().apply(listOf(summary)) + MutableLiveData(value) + } + + val result = defaultLocationSharingService.getLiveLocationShareSummary(AN_EVENT_ID).value + + result shouldBeEqualTo summary.toOptional() + } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt index 9b4ca332d5..d77084fe3b 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.test.fakes +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.zhuinden.monarchy.Monarchy import io.mockk.MockKVerificationScope @@ -60,10 +61,11 @@ internal class FakeMonarchy { realmEntities: List, mappedResult: List, mapper: Monarchy.Mapper - ) { + ): LiveData> { every { mapper.map(any()) } returns mockk() val monarchyQuery = slot>() val monarchyMapper = slot>() + val result = MutableLiveData(mappedResult) every { instance.findAllMappedWithChanges(capture(monarchyQuery), capture(monarchyMapper)) } answers { @@ -71,7 +73,8 @@ internal class FakeMonarchy { realmEntities.forEach { monarchyMapper.captured.map(it) } - MutableLiveData(mappedResult) + result } + return result } } diff --git a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt index ef7f0896b8..21b4e287c6 100644 --- a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt +++ b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt @@ -44,11 +44,11 @@ class ActiveSessionHolder @Inject constructor( private val guardServiceStarter: GuardServiceStarter ) { - private var activeSession: AtomicReference = AtomicReference() + private var activeSessionReference: AtomicReference = AtomicReference() fun setActiveSession(session: Session) { Timber.w("setActiveSession of ${session.myUserId}") - activeSession.set(session) + activeSessionReference.set(session) activeSessionDataSource.post(Option.just(session)) keyRequestHandler.start(session) @@ -68,7 +68,7 @@ class ActiveSessionHolder @Inject constructor( it.removeListener(sessionListener) } - activeSession.set(null) + activeSessionReference.set(null) activeSessionDataSource.post(Option.empty()) keyRequestHandler.stop() @@ -80,15 +80,15 @@ class ActiveSessionHolder @Inject constructor( } fun hasActiveSession(): Boolean { - return activeSession.get() != null + return activeSessionReference.get() != null } fun getSafeActiveSession(): Session? { - return activeSession.get() + return activeSessionReference.get() } fun getActiveSession(): Session { - return activeSession.get() + return activeSessionReference.get() ?: throw IllegalStateException("You should authenticate before using this") } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 1c2255246b..48f8aef421 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -53,6 +53,7 @@ import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.typing.TypingHelper import im.vector.app.features.location.LocationSharingServiceConnection +import im.vector.app.features.location.live.StopLiveLocationShareUseCase import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.raw.wellknown.getOutboundSessionKeySharingStrategyOrDefault @@ -92,6 +93,7 @@ import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.getStateEvent import org.matrix.android.sdk.api.session.room.getTimelineEvent +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.model.Membership @@ -133,8 +135,9 @@ class TimelineViewModel @AssistedInject constructor( private val decryptionFailureTracker: DecryptionFailureTracker, private val notificationDrawerManager: NotificationDrawerManager, private val locationSharingServiceConnection: LocationSharingServiceConnection, + private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase, timelineFactory: TimelineFactory, - appStateHandler: AppStateHandler + appStateHandler: AppStateHandler, ) : VectorViewModel(initialState), Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback { @@ -1139,7 +1142,12 @@ class TimelineViewModel @AssistedInject constructor( } private fun handleStopLiveLocationSharing() { - locationSharingServiceConnection.stopLiveLocationSharing(room.roomId) + viewModelScope.launch { + val result = stopLiveLocationShareUseCase.execute(room.roomId) + if (result is UpdateLiveLocationShareResult.Failure) { + _viewEvents.post(RoomDetailViewEvents.Failure(throwable = result.error, showInDialog = true)) + } + } } private fun observeRoomSummary() { @@ -1310,7 +1318,7 @@ class TimelineViewModel @AssistedInject constructor( // we should also mark it as read here, for the scenario that the user // is already in the thread timeline markThreadTimelineAsReadLocal() - locationSharingServiceConnection.unbind() + locationSharingServiceConnection.unbind(this) super.onCleared() } } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt index ef612eeec2..0ba9e2134c 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt @@ -23,17 +23,21 @@ import android.os.Parcelable import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.services.VectorService +import im.vector.app.features.location.live.GetLiveLocationShareSummaryUseCase import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.session.coroutineScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult import timber.log.Timber -import java.util.Timer -import java.util.TimerTask import javax.inject.Inject @AndroidEntryPoint @@ -49,6 +53,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { @Inject lateinit var notificationUtils: NotificationUtils @Inject lateinit var locationTracker: LocationTracker @Inject lateinit var activeSessionHolder: ActiveSessionHolder + @Inject lateinit var getLiveLocationShareSummaryUseCase: GetLiveLocationShareSummaryUseCase private val binder = LocalBinder() @@ -56,16 +61,27 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { * Keep track of a map between beacon event Id starting the live and RoomArgs. */ private val roomArgsMap = mutableMapOf() - private val timers = mutableListOf() var callback: Callback? = null + private val jobs = mutableListOf() override fun onCreate() { super.onCreate() Timber.i("### LocationSharingService.onCreate") + initLocationTracking() + } + + private fun initLocationTracking() { // Start tracking location locationTracker.addCallback(this) locationTracker.start() + + launchWithActiveSession { session -> + val job = locationTracker.locations + .onEach(this@LocationSharingService::onLocationUpdate) + .launchIn(session.coroutineScope) + jobs.add(job) + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -78,11 +94,8 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { val notification = notificationUtils.buildLiveLocationSharingNotification() startForeground(roomArgs.roomId.hashCode(), notification) - // Schedule a timer to stop sharing - scheduleTimer(roomArgs.roomId, roomArgs.durationMillis) - // Send beacon info state event - launchInIO { session -> + launchWithActiveSession { session -> sendStartingLiveBeaconInfo(session, roomArgs) } } @@ -100,7 +113,8 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { ?.let { result -> when (result) { is UpdateLiveLocationShareResult.Success -> { - roomArgsMap[result.beaconEventId] = roomArgs + addRoomArgs(result.beaconEventId, roomArgs) + listenForLiveSummaryChanges(roomArgs.roomId, result.beaconEventId) locationTracker.requestLastKnownLocation() } is UpdateLiveLocationShareResult.Failure -> { @@ -115,49 +129,13 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { } } - private fun scheduleTimer(roomId: String, durationMillis: Long) { - Timer() - .apply { - schedule(object : TimerTask() { - override fun run() { - stopSharingLocation(roomId) - timers.remove(this@apply) - } - }, durationMillis) - } - .also { - timers.add(it) - } - } - - fun stopSharingLocation(roomId: String) { + private fun stopSharingLocation(roomId: String) { Timber.i("### LocationSharingService.stopSharingLocation for $roomId") - - launchInIO { session -> - when (val result = sendStoppedBeaconInfo(session, roomId)) { - is UpdateLiveLocationShareResult.Success -> { - synchronized(roomArgsMap) { - val beaconIds = roomArgsMap - .filter { it.value.roomId == roomId } - .map { it.key } - beaconIds.forEach { roomArgsMap.remove(it) } - - tryToDestroyMe() - } - } - is UpdateLiveLocationShareResult.Failure -> callback?.onServiceError(result.error) - else -> Unit - } - } + removeRoomArgs(roomId) + tryToDestroyMe() } - private suspend fun sendStoppedBeaconInfo(session: Session, roomId: String): UpdateLiveLocationShareResult? { - return session.getRoom(roomId) - ?.locationSharingService() - ?.stopLiveLocationShare() - } - - override fun onLocationUpdate(locationData: LocationData) { + private fun onLocationUpdate(locationData: LocationData) { Timber.i("### LocationSharingService.onLocationUpdate. Uncertainty: ${locationData.uncertainty}") // Emit location update to all rooms in which live location sharing is active @@ -171,7 +149,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { beaconInfoEventId: String, locationData: LocationData ) { - launchInIO { session -> + launchWithActiveSession { session -> session.getRoom(roomId) ?.locationSharingService() ?.sendLiveLocation( @@ -191,29 +169,44 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { private fun tryToDestroyMe() { if (roomArgsMap.isEmpty()) { Timber.i("### LocationSharingService. Destroying self, time is up for all rooms") - destroyMe() + stopSelf() } } - private fun destroyMe() { - locationTracker.removeCallback(this) - timers.forEach { it.cancel() } - timers.clear() - stopSelf() - } - override fun onDestroy() { super.onDestroy() Timber.i("### LocationSharingService.onDestroy") - destroyMe() + jobs.forEach { it.cancel() } + jobs.clear() + locationTracker.removeCallback(this) } - private fun launchInIO(block: suspend CoroutineScope.(Session) -> Unit) = + private fun addRoomArgs(beaconEventId: String, roomArgs: RoomArgs) { + roomArgsMap[beaconEventId] = roomArgs + } + + private fun removeRoomArgs(roomId: String) { + roomArgsMap.toMap() + .filter { it.value.roomId == roomId } + .forEach { roomArgsMap.remove(it.key) } + } + + private fun listenForLiveSummaryChanges(roomId: String, eventId: String) { + launchWithActiveSession { session -> + val job = getLiveLocationShareSummaryUseCase.execute(roomId, eventId) + .distinctUntilChangedBy { it.isActive } + .filter { it.isActive == false } + .onEach { stopSharingLocation(roomId) } + .launchIn(session.coroutineScope) + jobs.add(job) + } + } + + private fun launchWithActiveSession(block: suspend CoroutineScope.(Session) -> Unit) = activeSessionHolder .getSafeActiveSession() ?.let { session -> session.coroutineScope.launch( - context = session.coroutineDispatchers.io, block = { block(session) } ) } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt index af09e0b1e0..db79564462 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt @@ -22,7 +22,9 @@ import android.content.Intent import android.content.ServiceConnection import android.os.IBinder import javax.inject.Inject +import javax.inject.Singleton +@Singleton class LocationSharingServiceConnection @Inject constructor( private val context: Context ) : ServiceConnection, LocationSharingService.Callback { @@ -33,12 +35,12 @@ class LocationSharingServiceConnection @Inject constructor( fun onLocationServiceError(error: Throwable) } - private var callback: Callback? = null + private val callbacks = mutableSetOf() private var isBound = false private var locationSharingService: LocationSharingService? = null fun bind(callback: Callback) { - this.callback = callback + addCallback(callback) if (isBound) { callback.onLocationServiceRunning() @@ -49,12 +51,8 @@ class LocationSharingServiceConnection @Inject constructor( } } - fun unbind() { - callback = null - } - - fun stopLiveLocationSharing(roomId: String) { - locationSharingService?.stopSharingLocation(roomId) + fun unbind(callback: Callback) { + removeCallback(callback) } override fun onServiceConnected(className: ComponentName, binder: IBinder) { @@ -62,17 +60,33 @@ class LocationSharingServiceConnection @Inject constructor( it.callback = this } isBound = true - callback?.onLocationServiceRunning() + onCallbackActionNoArg(Callback::onLocationServiceRunning) } override fun onServiceDisconnected(className: ComponentName) { isBound = false locationSharingService?.callback = null locationSharingService = null - callback?.onLocationServiceStopped() + onCallbackActionNoArg(Callback::onLocationServiceStopped) } override fun onServiceError(error: Throwable) { - callback?.onLocationServiceError(error) + forwardErrorToCallbacks(error) + } + + private fun addCallback(callback: Callback) { + callbacks.add(callback) + } + + private fun removeCallback(callback: Callback) { + callbacks.remove(callback) + } + + private fun onCallbackActionNoArg(action: Callback.() -> Unit) { + callbacks.toList().forEach(action) + } + + private fun forwardErrorToCallbacks(error: Throwable) { + callbacks.toList().forEach { it.onLocationServiceError(error) } } } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt index 30476d064f..b9a2dc830c 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt @@ -39,6 +39,7 @@ import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getUser import org.matrix.android.sdk.api.util.toMatrixItem +import timber.log.Timber /** * Sampling period to compare target location and user location. @@ -65,13 +66,20 @@ class LocationSharingViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() init { - locationTracker.addCallback(this) - locationTracker.start() + initLocationTracking() setUserItem() updatePin() compareTargetAndUserLocation() } + private fun initLocationTracking() { + locationTracker.addCallback(this) + locationTracker.locations + .onEach(::onLocationUpdate) + .launchIn(viewModelScope) + locationTracker.start() + } + private fun setUserItem() { setState { copy(userItem = session.getUser(session.myUserId)?.toMatrixItem()) } } @@ -172,7 +180,8 @@ class LocationSharingViewModel @AssistedInject constructor( ) } - override fun onLocationUpdate(locationData: LocationData) { + private fun onLocationUpdate(locationData: LocationData) { + Timber.d("onLocationUpdate()") setState { copy(lastKnownUserLocation = locationData) } 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 cdf13a7004..aa05fe764b 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 @@ -25,28 +25,27 @@ import androidx.annotation.VisibleForTesting import androidx.core.content.getSystemService import androidx.core.location.LocationListenerCompat import im.vector.app.BuildConfig -import im.vector.app.core.utils.Debouncer -import im.vector.app.core.utils.createBackgroundHandler +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton -private const val BKG_HANDLER_NAME = "LocationTracker.BKG_HANDLER_NAME" -private const val LOCATION_DEBOUNCE_ID = "LocationTracker.LOCATION_DEBOUNCE_ID" - @Singleton class LocationTracker @Inject constructor( - context: Context + context: Context, + private val activeSessionHolder: ActiveSessionHolder ) : LocationListenerCompat { private val locationManager = context.getSystemService() interface Callback { - /** - * Called on every location update. - */ - fun onLocationUpdate(locationData: LocationData) - /** * Called when no location provider is available to request location updates. */ @@ -62,9 +61,16 @@ class LocationTracker @Inject constructor( @VisibleForTesting var hasLocationFromGPSProvider = false - private var lastLocation: LocationData? = null + private val _locations = MutableSharedFlow(replay = 1) - private val debouncer = Debouncer(createBackgroundHandler(BKG_HANDLER_NAME)) + /** + * SharedFlow to collect location updates. + */ + val locations = _locations.asSharedFlow() + .onEach { Timber.d("new location emitted") } + .debounce(MIN_TIME_TO_UPDATE_LOCATION_MILLIS) + .onEach { Timber.d("new location emitted after debounce") } + .map { it.toLocationData() } @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) fun start() { @@ -119,33 +125,35 @@ class LocationTracker @Inject constructor( } @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) + @VisibleForTesting fun stop() { Timber.d("stop()") locationManager?.removeUpdates(this) - synchronized(this) { - callbacks.clear() - } - debouncer.cancelAll() + callbacks.clear() hasLocationFromGPSProvider = false hasLocationFromFusedProvider = false } /** - * Request the last known location. It will be given async through Callback. - * Please ensure adding a callback to receive the value. + * Request the last known location. It will be given async through corresponding flow. + * Please ensure collecting the flow before calling this method. */ fun requestLastKnownLocation() { - lastLocation?.let { locationData -> onLocationUpdate(locationData) } + Timber.d("requestLastKnownLocation") + activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch { + _locations.replayCache.firstOrNull()?.let { + Timber.d("emitting last location from cache") + _locations.emit(it) + } + } } - @Synchronized fun addCallback(callback: Callback) { if (!callbacks.contains(callback)) { callbacks.add(callback) } } - @Synchronized fun removeCallback(callback: Callback) { callbacks.remove(callback) if (callbacks.size == 0) { @@ -183,21 +191,19 @@ class LocationTracker @Inject constructor( } } - debouncer.debounce(LOCATION_DEBOUNCE_ID, MIN_TIME_TO_UPDATE_LOCATION_MILLIS) { - notifyLocation(location) - } + notifyLocation(location) } private fun notifyLocation(location: Location) { - if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { - Timber.d("notify location: $location") - } else { - Timber.d("notify location: ${location.provider}") - } + activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch { + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.d("notify location: $location") + } else { + Timber.d("notify location: ${location.provider}") + } - val locationData = location.toLocationData() - lastLocation = locationData - onLocationUpdate(locationData) + _locations.emit(location) + } } override fun onProviderDisabled(provider: String) { @@ -215,9 +221,8 @@ class LocationTracker @Inject constructor( } } - @Synchronized private fun onNoLocationProviderAvailable() { - callbacks.forEach { + callbacks.toList().forEach { try { it.onNoLocationProviderAvailable() } catch (error: Exception) { @@ -226,17 +231,6 @@ class LocationTracker @Inject constructor( } } - @Synchronized - private fun onLocationUpdate(locationData: LocationData) { - callbacks.forEach { - try { - it.onLocationUpdate(locationData) - } catch (error: Exception) { - Timber.e(error, "error in onLocationUpdate callback $it") - } - } - } - private fun Location.toLocationData(): LocationData { return LocationData(latitude, longitude, accuracy.toDouble()) } diff --git a/vector/src/main/java/im/vector/app/features/location/live/GetLiveLocationShareSummaryUseCase.kt b/vector/src/main/java/im/vector/app/features/location/live/GetLiveLocationShareSummaryUseCase.kt new file mode 100644 index 0000000000..0d8b70ccda --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/GetLiveLocationShareSummaryUseCase.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 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.live + +import androidx.lifecycle.asFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary +import timber.log.Timber +import javax.inject.Inject + +class GetLiveLocationShareSummaryUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String, eventId: String): Flow = withContext(session.coroutineDispatchers.main) { + Timber.d("getting flow for roomId=$roomId and eventId=$eventId") + session.getRoom(roomId) + ?.locationSharingService() + ?.getLiveLocationShareSummary(eventId) + ?.asFlow() + ?.mapNotNull { it.getOrNull() } + ?: emptyFlow() + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/live/StopLiveLocationShareUseCase.kt b/vector/src/main/java/im/vector/app/features/location/live/StopLiveLocationShareUseCase.kt new file mode 100644 index 0000000000..402c7ffb15 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/StopLiveLocationShareUseCase.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 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.live + +import im.vector.app.core.di.ActiveSessionHolder +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult +import javax.inject.Inject + +class StopLiveLocationShareUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder +) { + + suspend fun execute(roomId: String): UpdateLiveLocationShareResult? { + return sendStoppedBeaconInfo(roomId) + } + + private suspend fun sendStoppedBeaconInfo(roomId: String): UpdateLiveLocationShareResult? { + return activeSessionHolder.getActiveSession() + .getRoom(roomId) + ?.locationSharingService() + ?.stopLiveLocationShare() + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewModel.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewModel.kt index e89649709a..15c76b083e 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewModel.kt @@ -24,13 +24,17 @@ 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.LocationSharingServiceConnection +import im.vector.app.features.location.live.StopLiveLocationShareUseCase import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult class LocationLiveMapViewModel @AssistedInject constructor( @Assisted private val initialState: LocationLiveMapViewState, getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase, private val locationSharingServiceConnection: LocationSharingServiceConnection, + private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase, ) : VectorViewModel(initialState), LocationSharingServiceConnection.Callback { @AssistedFactory @@ -47,6 +51,11 @@ class LocationLiveMapViewModel @AssistedInject constructor( locationSharingServiceConnection.bind(this) } + override fun onCleared() { + locationSharingServiceConnection.unbind(this) + super.onCleared() + } + override fun handle(action: LocationLiveMapAction) { when (action) { is LocationLiveMapAction.AddMapSymbol -> handleAddMapSymbol(action) @@ -70,7 +79,12 @@ class LocationLiveMapViewModel @AssistedInject constructor( } private fun handleStopSharing() { - locationSharingServiceConnection.stopLiveLocationSharing(initialState.roomId) + viewModelScope.launch { + val result = stopLiveLocationShareUseCase.execute(initialState.roomId) + if (result is UpdateLiveLocationShareResult.Failure) { + _viewEvents.post(LocationLiveMapViewEvents.Error(result.error)) + } + } } override fun onLocationServiceRunning() { diff --git a/vector/src/test/java/im/vector/app/features/location/LocationTrackerTest.kt b/vector/src/test/java/im/vector/app/features/location/LocationTrackerTest.kt index b0f3974226..454a73cd70 100644 --- a/vector/src/test/java/im/vector/app/features/location/LocationTrackerTest.kt +++ b/vector/src/test/java/im/vector/app/features/location/LocationTrackerTest.kt @@ -19,21 +19,21 @@ package im.vector.app.features.location import android.content.Context import android.location.Location import android.location.LocationManager -import im.vector.app.core.utils.Debouncer -import im.vector.app.core.utils.createBackgroundHandler +import im.vector.app.features.session.coroutineScope +import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakeContext -import im.vector.app.test.fakes.FakeHandler import im.vector.app.test.fakes.FakeLocationManager +import im.vector.app.test.test import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.mockkConstructor import io.mockk.mockkStatic import io.mockk.runs -import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify import io.mockk.verifyOrder +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.After import org.junit.Before @@ -45,26 +45,18 @@ private const val AN_ACCURACY = 5.0f class LocationTrackerTest { - private val fakeHandler = FakeHandler() private val fakeLocationManager = FakeLocationManager() private val fakeContext = FakeContext().also { it.givenService(Context.LOCATION_SERVICE, android.location.LocationManager::class.java, fakeLocationManager.instance) } + private val fakeActiveSessionHolder = FakeActiveSessionHolder() private lateinit var locationTracker: LocationTracker @Before fun setUp() { - mockkConstructor(Debouncer::class) - every { anyConstructed().cancelAll() } just runs - val runnable = slot() - every { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, capture(runnable)) } answers { - runnable.captured.run() - true - } - mockkStatic("im.vector.app.core.utils.HandlerKt") - every { createBackgroundHandler(any()) } returns fakeHandler.instance - locationTracker = LocationTracker(fakeContext.instance) + mockkStatic("im.vector.app.features.session.SessionCoroutineScopesKt") + locationTracker = LocationTracker(fakeContext.instance, fakeActiveSessionHolder.instance) fakeLocationManager.givenRemoveUpdates(locationTracker) } @@ -139,13 +131,11 @@ class LocationTrackerTest { } @Test - fun `when location updates are received from fused provider then fused locations are taken in priority`() { + fun `when location updates are received from fused provider then fused locations are taken in priority`() = runTest { + every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER) mockAvailableProviders(providers) - val callback = mockCallback() - locationTracker.addCallback(callback) locationTracker.start() - val fusedLocation = mockLocation( provider = LocationManager.FUSED_PROVIDER, latitude = 1.0, @@ -159,29 +149,31 @@ class LocationTrackerTest { val networkLocation = mockLocation( provider = LocationManager.NETWORK_PROVIDER ) + val resultUpdates = locationTracker.locations.test(this) + locationTracker.onLocationChanged(fusedLocation) locationTracker.onLocationChanged(gpsLocation) locationTracker.onLocationChanged(networkLocation) + advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1) val expectedLocationData = LocationData( latitude = 1.0, longitude = 3.0, uncertainty = 4.0 ) - verify { callback.onLocationUpdate(expectedLocationData) } - verify { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) } + resultUpdates + .assertValues(listOf(expectedLocationData)) + .finish() locationTracker.hasLocationFromFusedProvider shouldBeEqualTo true locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false } @Test - fun `when location updates are received from gps provider then gps locations are taken if none are received from fused provider`() { + fun `when location updates are received from gps provider then gps locations are taken if none are received from fused provider`() = runTest { + every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER) mockAvailableProviders(providers) - val callback = mockCallback() - locationTracker.addCallback(callback) locationTracker.start() - val gpsLocation = mockLocation( provider = LocationManager.GPS_PROVIDER, latitude = 1.0, @@ -192,66 +184,75 @@ class LocationTrackerTest { val networkLocation = mockLocation( provider = LocationManager.NETWORK_PROVIDER ) + val resultUpdates = locationTracker.locations.test(this) + locationTracker.onLocationChanged(gpsLocation) locationTracker.onLocationChanged(networkLocation) + advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1) val expectedLocationData = LocationData( latitude = 1.0, longitude = 3.0, uncertainty = 4.0 ) - verify { callback.onLocationUpdate(expectedLocationData) } - verify { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) } + resultUpdates + .assertValues(listOf(expectedLocationData)) + .finish() locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false locationTracker.hasLocationFromGPSProvider shouldBeEqualTo true } @Test - fun `when location updates are received from network provider then network locations are taken if none are received from fused or gps provider`() { + fun `when location updates are received from network provider then network locations are taken if none are received from fused, gps provider`() = runTest { + every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER) mockAvailableProviders(providers) - val callback = mockCallback() - locationTracker.addCallback(callback) locationTracker.start() - val networkLocation = mockLocation( provider = LocationManager.NETWORK_PROVIDER, latitude = 1.0, longitude = 3.0, accuracy = 4f ) + val resultUpdates = locationTracker.locations.test(this) + locationTracker.onLocationChanged(networkLocation) + advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1) val expectedLocationData = LocationData( latitude = 1.0, longitude = 3.0, uncertainty = 4.0 ) - verify { callback.onLocationUpdate(expectedLocationData) } - verify { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) } + resultUpdates + .assertValues(listOf(expectedLocationData)) + .finish() locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false } @Test - fun `when requesting the last location then last location is notified via callback`() { + fun `when requesting the last location then last location is notified via location updates flow`() = runTest { + every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this val providers = listOf(LocationManager.GPS_PROVIDER) fakeLocationManager.givenActiveProviders(providers) val lastLocation = mockLocation(provider = LocationManager.GPS_PROVIDER) fakeLocationManager.givenLastLocationForProvider(provider = LocationManager.GPS_PROVIDER, location = lastLocation) fakeLocationManager.givenRequestUpdatesForProvider(provider = LocationManager.GPS_PROVIDER, listener = locationTracker) - val callback = mockCallback() - locationTracker.addCallback(callback) locationTracker.start() + val resultUpdates = locationTracker.locations.test(this) locationTracker.requestLastKnownLocation() + advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1) val expectedLocationData = LocationData( latitude = A_LATITUDE, longitude = A_LONGITUDE, uncertainty = AN_ACCURACY.toDouble() ) - verify { callback.onLocationUpdate(expectedLocationData) } + resultUpdates + .assertValues(listOf(expectedLocationData)) + .finish() } @Test @@ -259,7 +260,6 @@ class LocationTrackerTest { locationTracker.stop() verify { fakeLocationManager.instance.removeUpdates(locationTracker) } - verify { anyConstructed().cancelAll() } locationTracker.callbacks.isEmpty() shouldBeEqualTo true locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false @@ -276,7 +276,6 @@ class LocationTrackerTest { private fun mockCallback(): LocationTracker.Callback { return mockk().also { every { it.onNoLocationProviderAvailable() } just runs - every { it.onLocationUpdate(any()) } just runs } } diff --git a/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt index 7a80cbe87e..da3234cfd1 100644 --- a/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt @@ -16,21 +16,16 @@ package im.vector.app.features.location.domain.usecase -import com.airbnb.mvrx.test.MvRxTestRule import im.vector.app.features.location.LocationData import im.vector.app.test.fakes.FakeSession import io.mockk.MockKAnnotations import io.mockk.impl.annotations.OverrideMockKs import kotlinx.coroutines.test.runTest import org.junit.Before -import org.junit.Rule import org.junit.Test class CompareLocationsUseCaseTest { - @get:Rule - val mvRxTestRule = MvRxTestRule() - private val session = FakeSession() @OverrideMockKs diff --git a/vector/src/test/java/im/vector/app/features/location/live/GetLiveLocationShareSummaryUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/location/live/GetLiveLocationShareSummaryUseCaseTest.kt new file mode 100644 index 0000000000..fed825154c --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/location/live/GetLiveLocationShareSummaryUseCaseTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 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.live + +import im.vector.app.test.fakes.FakeFlowLiveDataConversions +import im.vector.app.test.fakes.FakeSession +import im.vector.app.test.fakes.givenAsFlowReturns +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent +import org.matrix.android.sdk.api.util.Optional + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" + +class GetLiveLocationShareSummaryUseCaseTest { + + private val fakeSession = FakeSession() + private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions() + + private val getLiveLocationShareSummaryUseCase = GetLiveLocationShareSummaryUseCase( + session = fakeSession + ) + + @Before + fun setUp() { + fakeFlowLiveDataConversions.setup() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a room id and event id when calling use case then live data on summary is returned`() = runTest { + val summary = LiveLocationShareAggregatedSummary( + userId = "userId", + isActive = true, + endOfLiveTimestampMillis = 123, + lastLocationDataContent = MessageBeaconLocationDataContent() + ) + fakeSession.roomService() + .getRoom(A_ROOM_ID) + .locationSharingService() + .givenLiveLocationShareSummaryReturns(AN_EVENT_ID, summary) + .givenAsFlowReturns(Optional(summary)) + + val result = getLiveLocationShareSummaryUseCase.execute(A_ROOM_ID, AN_EVENT_ID).first() + + result shouldBeEqualTo summary + } +} diff --git a/vector/src/test/java/im/vector/app/features/location/live/StopLiveLocationShareUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/location/live/StopLiveLocationShareUseCaseTest.kt new file mode 100644 index 0000000000..36fef4fd7b --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/location/live/StopLiveLocationShareUseCaseTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 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.live + +import im.vector.app.test.fakes.FakeActiveSessionHolder +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" + +class StopLiveLocationShareUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + + private val stopLiveLocationShareUseCase = StopLiveLocationShareUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a room id when calling use case then the current live is stopped with success`() = runTest { + val updateLiveResult = UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + fakeActiveSessionHolder + .fakeSession + .roomService() + .getRoom(A_ROOM_ID) + .locationSharingService() + .givenStopLiveLocationShareReturns(updateLiveResult) + + val result = stopLiveLocationShareUseCase.execute(A_ROOM_ID) + + result shouldBeEqualTo updateLiveResult + } + + @Test + fun `given a room id and error during the process when calling use case then result is failure`() = runTest { + val error = Throwable() + val updateLiveResult = UpdateLiveLocationShareResult.Failure(error) + fakeActiveSessionHolder + .fakeSession + .roomService() + .getRoom(A_ROOM_ID) + .locationSharingService() + .givenStopLiveLocationShareReturns(updateLiveResult) + + val result = stopLiveLocationShareUseCase.execute(A_ROOM_ID) + + result shouldBeEqualTo updateLiveResult + } +} diff --git a/vector/src/test/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCaseTest.kt index 8a5a30e612..420b8e6a06 100644 --- a/vector/src/test/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCaseTest.kt @@ -16,52 +16,48 @@ package im.vector.app.features.location.live.map -import androidx.lifecycle.asFlow -import com.airbnb.mvrx.test.MvRxTestRule import im.vector.app.features.location.LocationData +import im.vector.app.test.fakes.FakeFlowLiveDataConversions import im.vector.app.test.fakes.FakeSession +import im.vector.app.test.fakes.givenAsFlowReturns import io.mockk.coEvery -import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic +import io.mockk.unmockkAll import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.shouldBeEqualTo import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.util.MatrixItem +private const val A_ROOM_ID = "room_id" + class GetListOfUserLiveLocationUseCaseTest { - @get:Rule - val mvRxTestRule = MvRxTestRule() - private val fakeSession = FakeSession() - private val viewStateMapper = mockk() + private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions() - private val getListOfUserLiveLocationUseCase = GetListOfUserLiveLocationUseCase(fakeSession, viewStateMapper) + private val getListOfUserLiveLocationUseCase = GetListOfUserLiveLocationUseCase( + session = fakeSession, + userLiveLocationViewStateMapper = viewStateMapper + ) @Before fun setUp() { - mockkStatic("androidx.lifecycle.FlowLiveDataConversions") + fakeFlowLiveDataConversions.setup() } @After fun tearDown() { - unmockkStatic("androidx.lifecycle.FlowLiveDataConversions") + unmockkAll() } @Test fun `given a room id then the correct flow of view states list is collected`() = runTest { - val roomId = "roomId" - val summary1 = LiveLocationShareAggregatedSummary( userId = "userId1", isActive = true, @@ -81,12 +77,11 @@ class GetListOfUserLiveLocationUseCaseTest { lastLocationDataContent = MessageBeaconLocationDataContent() ) val summaries = listOf(summary1, summary2, summary3) - val liveData = fakeSession.roomService() - .getRoom(roomId) + fakeSession.roomService() + .getRoom(A_ROOM_ID) .locationSharingService() - .givenRunningLiveLocationShareSummaries(summaries) - - every { liveData.asFlow() } returns flowOf(summaries) + .givenRunningLiveLocationShareSummariesReturns(summaries) + .givenAsFlowReturns(summaries) val viewState1 = UserLiveLocationViewState( matrixItem = MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""), @@ -108,8 +103,8 @@ class GetListOfUserLiveLocationUseCaseTest { coEvery { viewStateMapper.map(summary2) } returns viewState2 coEvery { viewStateMapper.map(summary3) } returns null - val viewStates = getListOfUserLiveLocationUseCase.execute(roomId).first() + val viewStates = getListOfUserLiveLocationUseCase.execute(A_ROOM_ID).first() - assertEquals(listOf(viewState1, viewState2), viewStates) + viewStates shouldBeEqualTo listOf(viewState1, viewState2) } } diff --git a/vector/src/test/java/im/vector/app/features/location/live/map/LocationLiveMapViewModelTest.kt b/vector/src/test/java/im/vector/app/features/location/live/map/LocationLiveMapViewModelTest.kt index b477265506..dd1a894a28 100644 --- a/vector/src/test/java/im/vector/app/features/location/live/map/LocationLiveMapViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/location/live/map/LocationLiveMapViewModelTest.kt @@ -18,39 +18,47 @@ package im.vector.app.features.location.live.map import com.airbnb.mvrx.test.MvRxTestRule import im.vector.app.features.location.LocationData -import im.vector.app.features.location.LocationSharingServiceConnection +import im.vector.app.features.location.live.StopLiveLocationShareUseCase +import im.vector.app.test.fakes.FakeLocationSharingServiceConnection import im.vector.app.test.test import io.mockk.every -import io.mockk.just import io.mockk.mockk -import io.mockk.runs -import io.mockk.verify +import io.mockk.unmockkAll import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Rule import org.junit.Test import org.matrix.android.sdk.api.util.MatrixItem +private const val A_ROOM_ID = "room_id" + class LocationLiveMapViewModelTest { @get:Rule - val mvrxTestRule = MvRxTestRule() + val mvRxTestRule = MvRxTestRule(testDispatcher = UnconfinedTestDispatcher()) - private val fakeRoomId = "" - - private val args = LocationLiveMapViewArgs(roomId = fakeRoomId) + private val args = LocationLiveMapViewArgs(roomId = A_ROOM_ID) private val getListOfUserLiveLocationUseCase = mockk() - private val locationServiceConnection = mockk() + private val locationServiceConnection = FakeLocationSharingServiceConnection() + private val stopLiveLocationShareUseCase = mockk() private fun createViewModel(): LocationLiveMapViewModel { return LocationLiveMapViewModel( LocationLiveMapViewState(args), getListOfUserLiveLocationUseCase, - locationServiceConnection + locationServiceConnection.instance, + stopLiveLocationShareUseCase ) } + @After + fun tearDown() { + unmockkAll() + } + @Test fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest { val userLocations = listOf( @@ -63,8 +71,8 @@ class LocationLiveMapViewModelTest { showStopSharingButton = false ) ) - every { locationServiceConnection.bind(any()) } just runs - every { getListOfUserLiveLocationUseCase.execute(fakeRoomId) } returns flowOf(userLocations) + locationServiceConnection.givenBind() + every { getListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations) val viewModel = createViewModel() viewModel @@ -76,6 +84,6 @@ class LocationLiveMapViewModelTest { ) .finish() - verify { locationServiceConnection.bind(viewModel) } + locationServiceConnection.verifyBind(viewModel) } } diff --git a/vector/src/test/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCaseTest.kt index d45e6e7ce1..87dbabee0a 100644 --- a/vector/src/test/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCaseTest.kt @@ -19,7 +19,6 @@ package im.vector.app.features.media.domain.usecase import android.content.Context import android.net.Uri import androidx.core.net.toUri -import com.airbnb.mvrx.test.MvRxTestRule import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.utils.saveMedia import im.vector.app.features.notifications.NotificationUtils @@ -42,14 +41,10 @@ import io.mockk.verifyAll import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test class DownloadMediaUseCaseTest { - @get:Rule - val mvRxTestRule = MvRxTestRule() - @MockK lateinit var appContext: Context diff --git a/vector/src/test/java/im/vector/app/test/TestCoroutineDispatchers.kt b/vector/src/test/java/im/vector/app/test/TestCoroutineDispatchers.kt index bf24d146e6..fb3c1bb70a 100644 --- a/vector/src/test/java/im/vector/app/test/TestCoroutineDispatchers.kt +++ b/vector/src/test/java/im/vector/app/test/TestCoroutineDispatchers.kt @@ -16,13 +16,15 @@ package im.vector.app.test -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.matrix.android.sdk.api.MatrixCoroutineDispatchers +private val testDispatcher = UnconfinedTestDispatcher() + internal val testCoroutineDispatchers = MatrixCoroutineDispatchers( - io = Dispatchers.Main, - computation = Dispatchers.Main, - main = Dispatchers.Main, - crypto = Dispatchers.Main, - dmVerif = Dispatchers.Main + io = testDispatcher, + computation = testDispatcher, + main = testDispatcher, + crypto = testDispatcher, + dmVerif = testDispatcher ) diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt index d0825a0043..3065c18c30 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt @@ -23,10 +23,11 @@ import io.mockk.mockk import org.matrix.android.sdk.api.session.Session class FakeActiveSessionHolder( - private val fakeSession: FakeSession = FakeSession() + val fakeSession: FakeSession = FakeSession() ) { val instance = mockk { every { getActiveSession() } returns fakeSession + every { getSafeActiveSession() } returns fakeSession } fun expectSetsActiveSession(session: Session) { diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeFlowLiveDataConversions.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeFlowLiveDataConversions.kt new file mode 100644 index 0000000000..9abbcc174d --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeFlowLiveDataConversions.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 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 androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow +import io.mockk.every +import io.mockk.mockkStatic +import kotlinx.coroutines.flow.flowOf + +class FakeFlowLiveDataConversions { + fun setup() { + mockkStatic("androidx.lifecycle.FlowLiveDataConversions") + } +} + +fun LiveData.givenAsFlowReturns(value: T) { + every { asFlow() } returns flowOf(value) +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingService.kt index 2cd98c086c..cebd45b2bb 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingService.kt @@ -18,17 +18,34 @@ package im.vector.app.test.fakes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import org.matrix.android.sdk.api.session.room.location.LocationSharingService +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary +import org.matrix.android.sdk.api.util.Optional class FakeLocationSharingService : LocationSharingService by mockk() { - fun givenRunningLiveLocationShareSummaries(summaries: List): - LiveData> { + fun givenRunningLiveLocationShareSummariesReturns( + summaries: List + ): LiveData> { return MutableLiveData(summaries).also { every { getRunningLiveLocationShareSummaries() } returns it } } + + fun givenLiveLocationShareSummaryReturns( + eventId: String, + summary: LiveLocationShareAggregatedSummary + ): LiveData> { + return MutableLiveData(Optional(summary)).also { + every { getLiveLocationShareSummary(eventId) } returns it + } + } + + fun givenStopLiveLocationShareReturns(result: UpdateLiveLocationShareResult) { + coEvery { stopLiveLocationShare() } returns result + } } 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 new file mode 100644 index 0000000000..db27a894f9 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingServiceConnection.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 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.LocationSharingServiceConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class FakeLocationSharingServiceConnection { + + val instance = mockk() + + fun givenBind() { + every { instance.bind(any()) } just runs + } + + fun verifyBind(callback: LocationSharingServiceConnection.Callback) { + verify { instance.bind(callback) } + } +}