Merge pull request #6356 from vector-im/fix/mna/stop-lls-from-other-device

[Location sharing] - Make stop of a live from another device possible (PSF-1060)
This commit is contained in:
Maxime NATUREL 2022-06-29 09:45:44 +02:00 committed by GitHub
commit d112f860a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 649 additions and 232 deletions

1
changelog.d/6349.bugfix Normal file
View file

@ -0,0 +1 @@
[Location sharing] Fix stop of a live not possible from another device

View file

@ -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<List<LiveLocationShareAggregatedSummary>>
/**
* 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<Optional<LiveLocationShareAggregatedSummary>>
}

View file

@ -76,7 +76,7 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findActiveLiveIn
realm: Realm,
roomId: String,
userId: String,
ignoredEventId: String
ignoredEventId: String,
): List<LiveLocationShareAggregatedSummaryEntity> {
return LiveLocationShareAggregatedSummaryEntity
.whereRoomId(realm, roomId = roomId)

View file

@ -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<Optional<LiveLocationShareAggregatedSummary>> {
return Transformations.map(
monarchy.findAllMappedWithChanges(
{ LiveLocationShareAggregatedSummaryEntity.where(it, roomId = roomId, eventId = beaconInfoEventId) },
liveLocationShareAggregatedSummaryMapper
)
) {
it.firstOrNull().toOptional()
}
}
}

View file

@ -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<SendStaticLocationTask>()
private val sendLiveLocationTask = mockk<SendLiveLocationTask>()
@ -55,7 +63,7 @@ internal class DefaultLocationSharingServiceTest {
private val fakeLiveLocationShareAggregatedSummaryMapper = mockk<LiveLocationShareAggregatedSummaryMapper>()
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<LiveLocationShareAggregatedSummaryEntity>()
.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<LiveLocationShareAggregatedSummaryEntity>()
.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<Function<List<LiveLocationShareAggregatedSummary>, Optional<LiveLocationShareAggregatedSummary>>>()
every {
Transformations.map(
liveData,
capture(mapper)
)
} answers {
val value = secondArg<Function<List<LiveLocationShareAggregatedSummary>, Optional<LiveLocationShareAggregatedSummary>>>().apply(listOf(summary))
MutableLiveData(value)
}
val result = defaultLocationSharingService.getLiveLocationShareSummary(AN_EVENT_ID).value
result shouldBeEqualTo summary.toOptional()
}
}

View file

@ -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<T>,
mappedResult: List<R>,
mapper: Monarchy.Mapper<R, T>
) {
): LiveData<List<R>> {
every { mapper.map(any()) } returns mockk()
val monarchyQuery = slot<Monarchy.Query<T>>()
val monarchyMapper = slot<Monarchy.Mapper<R, T>>()
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
}
}

View file

@ -44,11 +44,11 @@ class ActiveSessionHolder @Inject constructor(
private val guardServiceStarter: GuardServiceStarter
) {
private var activeSession: AtomicReference<Session?> = AtomicReference()
private var activeSessionReference: AtomicReference<Session?> = 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")
}

View file

@ -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<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(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()
}
}

View file

@ -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<String, RoomArgs>()
private val timers = mutableListOf<Timer>()
var callback: Callback? = null
private val jobs = mutableListOf<Job>()
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) }
)
}

View file

@ -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<Callback>()
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) }
}
}

View file

@ -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<LocationSharingViewModel, LocationSharingViewState> 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)
}

View file

@ -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<LocationManager>()
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<Location>(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())
}

View file

@ -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<LiveLocationShareAggregatedSummary> = 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()
}
}

View file

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

View file

@ -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<LocationLiveMapViewState, LocationLiveMapAction, LocationLiveMapViewEvents>(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() {

View file

@ -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<Debouncer>().cancelAll() } just runs
val runnable = slot<Runnable>()
every { anyConstructed<Debouncer>().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<Debouncer>().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<Debouncer>().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<Debouncer>().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<Debouncer>().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<LocationTracker.Callback>().also {
every { it.onNoLocationProviderAvailable() } just runs
every { it.onLocationUpdate(any()) } just runs
}
}

View file

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

View file

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

View file

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

View file

@ -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<UserLiveLocationViewStateMapper>()
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)
}
}

View file

@ -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<GetListOfUserLiveLocationUseCase>()
private val locationServiceConnection = mockk<LocationSharingServiceConnection>()
private val locationServiceConnection = FakeLocationSharingServiceConnection()
private val stopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>()
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)
}
}

View file

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

View file

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

View file

@ -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<ActiveSessionHolder> {
every { getActiveSession() } returns fakeSession
every { getSafeActiveSession() } returns fakeSession
}
fun expectSetsActiveSession(session: Session) {

View file

@ -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 <T> LiveData<T>.givenAsFlowReturns(value: T) {
every { asFlow() } returns flowOf(value)
}

View file

@ -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<LiveLocationShareAggregatedSummary>):
LiveData<List<LiveLocationShareAggregatedSummary>> {
fun givenRunningLiveLocationShareSummariesReturns(
summaries: List<LiveLocationShareAggregatedSummary>
): LiveData<List<LiveLocationShareAggregatedSummary>> {
return MutableLiveData(summaries).also {
every { getRunningLiveLocationShareSummaries() } returns it
}
}
fun givenLiveLocationShareSummaryReturns(
eventId: String,
summary: LiveLocationShareAggregatedSummary
): LiveData<Optional<LiveLocationShareAggregatedSummary>> {
return MutableLiveData(Optional(summary)).also {
every { getLiveLocationShareSummary(eventId) } returns it
}
}
fun givenStopLiveLocationShareReturns(result: UpdateLiveLocationShareResult) {
coEvery { stopLiveLocationShare() } returns result
}
}

View file

@ -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<LocationSharingServiceConnection>()
fun givenBind() {
every { instance.bind(any()) } just runs
}
fun verifyBind(callback: LocationSharingServiceConnection.Callback) {
verify { instance.bind(callback) }
}
}