Merge pull request #6129 from vector-im/feature/mna/PSF-1019-user-pins

[Location sharing] - Show user live location pins in map view (PSF-1019)
This commit is contained in:
Maxime NATUREL 2022-05-30 12:32:09 +02:00 committed by GitHub
commit eeaf9fd616
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1071 additions and 27 deletions

View file

@ -1 +1,2 @@
Live location sharing: navigation from timeline to map screen
Live location sharing: show user pins on map screen

View file

@ -22,6 +22,7 @@ import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataServic
import org.matrix.android.sdk.api.session.room.alias.AliasService
import org.matrix.android.sdk.api.session.room.call.RoomCallService
import org.matrix.android.sdk.api.session.room.crypto.RoomCryptoService
import org.matrix.android.sdk.api.session.room.location.LocationSharingService
import org.matrix.android.sdk.api.session.room.members.MembershipService
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.relation.RelationService
@ -163,4 +164,9 @@ interface Room {
* Get the RoomVersionService associated to this Room.
*/
fun roomVersionService(): RoomVersionService
/**
* Get the LocationSharingService associated to this Room.
*/
fun locationSharingService(): LocationSharingService
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.location
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
/**
* Manage all location sharing related features.
*/
interface LocationSharingService {
fun getRunningLiveLocationShareSummaries(): LiveData<List<LiveLocationShareAggregatedSummary>>
}

View file

@ -22,6 +22,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocati
* Aggregation info concerning a live location share.
*/
data class LiveLocationShareAggregatedSummary(
val userId: String?,
/**
* Indicate whether the live is currently running.
*/

View file

@ -46,6 +46,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo025
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo026
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo027
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo028
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo029
import org.matrix.android.sdk.internal.util.Normalizer
import timber.log.Timber
import javax.inject.Inject
@ -60,7 +61,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
override fun equals(other: Any?) = other is RealmSessionStoreMigration
override fun hashCode() = 1000
val schemaVersion = 28L
val schemaVersion = 29L
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
Timber.d("Migrating Realm Session from $oldVersion to $newVersion")
@ -93,5 +94,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 26) MigrateSessionTo026(realm).perform()
if (oldVersion < 27) MigrateSessionTo027(realm).perform()
if (oldVersion < 28) MigrateSessionTo028(realm).perform()
if (oldVersion < 29) MigrateSessionTo029(realm).perform()
}
}

View file

@ -58,7 +58,7 @@ internal object EventAnnotationsSummaryMapper {
PollResponseAggregatedSummaryEntityMapper.map(it)
},
liveLocationShareAggregatedSummary = annotationsSummary.liveLocationShareAggregatedSummary?.let {
LiveLocationShareAggregatedSummaryMapper.map(it)
LiveLocationShareAggregatedSummaryMapper().map(it)
}
)
}

View file

@ -20,11 +20,13 @@ import org.matrix.android.sdk.api.session.events.model.toModel
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.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import javax.inject.Inject
internal object LiveLocationShareAggregatedSummaryMapper {
internal class LiveLocationShareAggregatedSummaryMapper @Inject constructor() {
fun map(entity: LiveLocationShareAggregatedSummaryEntity): LiveLocationShareAggregatedSummary {
return LiveLocationShareAggregatedSummary(
userId = entity.userId,
isActive = entity.isActive,
endOfLiveTimestampMillis = entity.endOfLiveTimestampMillis,
lastLocationDataContent = ContentMapper.map(entity.lastLocationContent).toModel<MessageBeaconLocationDataContent>()

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.database.migration
import io.realm.DynamicRealm
import io.realm.FieldAttribute
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.util.database.RealmMigrator
/**
* Migrating to:
* Live location sharing aggregated summary: adding new field userId.
*/
internal class MigrateSessionTo029(realm: DynamicRealm) : RealmMigrator(realm, 28) {
override fun doMigrate(realm: DynamicRealm) {
realm.schema.get("LiveLocationShareAggregatedSummaryEntity")
?.addField(LiveLocationShareAggregatedSummaryEntityFields.USER_ID, String::class.java, FieldAttribute.REQUIRED)
?.transform { obj ->
obj.setString(LiveLocationShareAggregatedSummaryEntityFields.USER_ID, "")
}
}
}

View file

@ -31,6 +31,8 @@ internal open class LiveLocationShareAggregatedSummaryEntity(
var roomId: String = "",
var userId: String = "",
/**
* Indicate whether the live is currently running.
*/

View file

@ -28,9 +28,15 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.where(
roomId: String,
eventId: String,
): RealmQuery<LiveLocationShareAggregatedSummaryEntity> {
return LiveLocationShareAggregatedSummaryEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, eventId)
}
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.whereRoomId(realm: Realm,
roomId: String): RealmQuery<LiveLocationShareAggregatedSummaryEntity> {
return realm.where<LiveLocationShareAggregatedSummaryEntity>()
.equalTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, roomId)
.equalTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, eventId)
}
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.create(
@ -63,3 +69,31 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.get(
): LiveLocationShareAggregatedSummaryEntity? {
return LiveLocationShareAggregatedSummaryEntity.where(realm, roomId, eventId).findFirst()
}
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findActiveLiveInRoomForUser(
realm: Realm,
roomId: String,
userId: String,
ignoredEventId: String
): List<LiveLocationShareAggregatedSummaryEntity> {
return LiveLocationShareAggregatedSummaryEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(LiveLocationShareAggregatedSummaryEntityFields.USER_ID, userId)
.equalTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true)
.notEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, ignoredEventId)
.findAll()
}
/**
* A live is considered as running when active and with at least a last known location.
*/
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findRunningLiveInRoom(
realm: Realm,
roomId: String,
): RealmQuery<LiveLocationShareAggregatedSummaryEntity> {
return LiveLocationShareAggregatedSummaryEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true)
.isNotEmpty(LiveLocationShareAggregatedSummaryEntityFields.USER_ID)
.isNotNull(LiveLocationShareAggregatedSummaryEntityFields.LAST_LOCATION_CONTENT)
}

View file

@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataServic
import org.matrix.android.sdk.api.session.room.alias.AliasService
import org.matrix.android.sdk.api.session.room.call.RoomCallService
import org.matrix.android.sdk.api.session.room.crypto.RoomCryptoService
import org.matrix.android.sdk.api.session.room.location.LocationSharingService
import org.matrix.android.sdk.api.session.room.members.MembershipService
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.RoomType
@ -69,6 +70,7 @@ internal class DefaultRoom(
private val roomAccountDataService: RoomAccountDataService,
private val roomVersionService: RoomVersionService,
private val viaParameterFinder: ViaParameterFinder,
private val locationSharingService: LocationSharingService,
override val coroutineDispatchers: MatrixCoroutineDispatchers
) : Room {
@ -104,4 +106,5 @@ internal class DefaultRoom(
override fun roomPushRuleService() = roomPushRuleService
override fun roomAccountDataService() = roomAccountDataService
override fun roomVersionService() = roomVersionService
override fun locationSharingService() = locationSharingService
}

View file

@ -25,6 +25,7 @@ import org.matrix.android.sdk.internal.session.room.alias.DefaultAliasService
import org.matrix.android.sdk.internal.session.room.call.DefaultRoomCallService
import org.matrix.android.sdk.internal.session.room.crypto.DefaultRoomCryptoService
import org.matrix.android.sdk.internal.session.room.draft.DefaultDraftService
import org.matrix.android.sdk.internal.session.room.location.DefaultLocationSharingService
import org.matrix.android.sdk.internal.session.room.membership.DefaultMembershipService
import org.matrix.android.sdk.internal.session.room.notification.DefaultRoomPushRuleService
import org.matrix.android.sdk.internal.session.room.read.DefaultReadService
@ -69,6 +70,7 @@ internal class DefaultRoomFactory @Inject constructor(
private val roomVersionServiceFactory: DefaultRoomVersionService.Factory,
private val roomAccountDataServiceFactory: DefaultRoomAccountDataService.Factory,
private val viaParameterFinder: ViaParameterFinder,
private val locationSharingServiceFactory: DefaultLocationSharingService.Factory,
private val coroutineDispatchers: MatrixCoroutineDispatchers
) : RoomFactory {
@ -96,6 +98,7 @@ internal class DefaultRoomFactory @Inject constructor(
roomAccountDataService = roomAccountDataServiceFactory.create(roomId),
roomVersionService = roomVersionServiceFactory.create(roomId),
viaParameterFinder = viaParameterFinder,
locationSharingService = locationSharingServiceFactory.create(roomId),
coroutineDispatchers = coroutineDispatchers
)
}

View file

@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoCo
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.internal.database.mapper.ContentMapper
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.query.findActiveLiveInRoomForUser
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.di.SessionId
import org.matrix.android.sdk.internal.di.WorkManagerProvider
@ -35,6 +36,7 @@ import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
// TODO add unit tests
internal class LiveLocationAggregationProcessor @Inject constructor(
@SessionId private val sessionId: String,
private val workManagerProvider: WorkManagerProvider,
@ -70,6 +72,9 @@ internal class LiveLocationAggregationProcessor @Inject constructor(
val endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) }
aggregatedSummary.endOfLiveTimestampMillis = endOfLiveTimestampMillis
aggregatedSummary.isActive = isLive
aggregatedSummary.userId = event.senderId
deactivateAllPreviousBeacons(realm, roomId, event.senderId, targetEventId)
if (isLive) {
scheduleDeactivationAfterTimeout(targetEventId, roomId, endOfLiveTimestampMillis)
@ -137,5 +142,16 @@ internal class LiveLocationAggregationProcessor @Inject constructor(
}
}
private fun deactivateAllPreviousBeacons(realm: Realm, roomId: String, userId: String, currentEventId: String) {
LiveLocationShareAggregatedSummaryEntity
.findActiveLiveInRoomForUser(
realm = realm,
roomId = roomId,
userId = userId,
ignoredEventId = currentEventId
)
.forEach { it.isActive = false }
}
private fun Long.isMoreRecentThan(timestamp: Long) = this > timestamp
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.room.location
import androidx.lifecycle.LiveData
import com.zhuinden.monarchy.Monarchy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.matrix.android.sdk.api.session.room.location.LocationSharingService
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
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.di.SessionDatabase
// TODO add unit tests
internal class DefaultLocationSharingService @AssistedInject constructor(
@Assisted private val roomId: String,
@SessionDatabase private val monarchy: Monarchy,
private val liveLocationShareAggregatedSummaryMapper: LiveLocationShareAggregatedSummaryMapper,
) : LocationSharingService {
@AssistedFactory
interface Factory {
fun create(roomId: String): DefaultLocationSharingService
}
override fun getRunningLiveLocationShareSummaries(): LiveData<List<LiveLocationShareAggregatedSummary>> {
return monarchy.findAllMappedWithChanges(
{ LiveLocationShareAggregatedSummaryEntity.findRunningLiveInRoom(it, roomId = roomId) },
{ liveLocationShareAggregatedSummaryMapper.map(it) }
)
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.database.mapper
import com.squareup.moshi.Moshi
import org.amshove.kluent.shouldBeEqualTo
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.LocationInfo
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
private const val ANY_USER_ID = "a-user-id"
private const val ANY_ACTIVE_STATE = true
private const val ANY_TIMEOUT = 123L
private val A_LOCATION_INFO = LocationInfo("a-geo-uri")
class LiveLocationShareAggregatedSummaryMapperTest {
private val mapper = LiveLocationShareAggregatedSummaryMapper()
@Test
fun `given an entity then result should be mapped correctly`() {
val entity = anEntity(content = MessageBeaconLocationDataContent(locationInfo = A_LOCATION_INFO))
val summary = mapper.map(entity)
summary shouldBeEqualTo LiveLocationShareAggregatedSummary(
userId = ANY_USER_ID,
isActive = ANY_ACTIVE_STATE,
endOfLiveTimestampMillis = ANY_TIMEOUT,
lastLocationDataContent = MessageBeaconLocationDataContent(locationInfo = A_LOCATION_INFO)
)
}
private fun anEntity(content: MessageBeaconLocationDataContent) = LiveLocationShareAggregatedSummaryEntity(
userId = ANY_USER_ID,
isActive = ANY_ACTIVE_STATE,
endOfLiveTimestampMillis = ANY_TIMEOUT,
lastLocationContent = Moshi.Builder().build().adapter(MessageBeaconLocationDataContent::class.java).toJson(content)
)
}

View file

@ -54,6 +54,7 @@ import im.vector.app.features.home.room.list.RoomListViewModel
import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel
import im.vector.app.features.invite.InviteUsersToRoomViewModel
import im.vector.app.features.location.LocationSharingViewModel
import im.vector.app.features.location.live.map.LocationLiveMapViewModel
import im.vector.app.features.login.LoginViewModel
import im.vector.app.features.login2.LoginViewModel2
import im.vector.app.features.login2.created.AccountCreatedViewModel
@ -600,4 +601,9 @@ interface MavericksViewModelModule {
@IntoMap
@MavericksViewModelKey(VectorAttachmentViewerViewModel::class)
fun vectorAttachmentViewerViewModelFactory(factory: VectorAttachmentViewerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(LocationLiveMapViewModel::class)
fun locationLiveMapViewModelFactory(factory: LocationLiveMapViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}

View file

@ -181,7 +181,7 @@ class LocationSharingFragment @Inject constructor(
}
private fun handleZoomToUserLocationEvent(event: LocationSharingViewEvents.ZoomToUserLocation) {
views.mapView.zoomToLocation(event.userLocation.latitude, event.userLocation.longitude)
views.mapView.zoomToLocation(event.userLocation)
}
private fun handleStartLiveLocationService(event: LocationSharingViewEvents.StartLiveLocationService) {

View file

@ -0,0 +1,39 @@
/*
* 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
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.constants.MapboxConstants
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.geometry.LatLngBounds
import com.mapbox.mapboxsdk.maps.MapboxMap
fun MapboxMap?.zoomToLocation(locationData: LocationData) {
this?.cameraPosition = CameraPosition.Builder()
.target(LatLng(locationData.latitude, locationData.longitude))
.zoom(INITIAL_MAP_ZOOM_IN_PREVIEW)
.build()
}
fun MapboxMap?.zoomToBounds(latLngBounds: LatLngBounds) {
this?.getCameraForLatLngBounds(latLngBounds)?.let { camPosition ->
// unZoom a little to avoid having pins exactly at the edges of the map
cameraPosition = CameraPosition.Builder(camPosition)
.zoom((camPosition.zoom - 1).coerceAtLeast(MapboxConstants.MINIMUM_ZOOM.toDouble()))
.build()
}
}

View file

@ -25,7 +25,6 @@ import androidx.core.content.ContextCompat
import androidx.core.view.marginBottom
import androidx.core.view.marginTop
import androidx.core.view.updateLayoutParams
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.maps.MapView
import com.mapbox.mapboxsdk.maps.MapboxMap
@ -164,7 +163,7 @@ class MapTilerMapView @JvmOverloads constructor(
state.userLocationData?.let { locationData ->
if (!initZoomDone || !state.zoomOnlyOnce) {
zoomToLocation(locationData.latitude, locationData.longitude)
zoomToLocation(locationData)
initZoomDone = true
}
@ -180,12 +179,9 @@ class MapTilerMapView @JvmOverloads constructor(
}
}
fun zoomToLocation(latitude: Double, longitude: Double) {
fun zoomToLocation(locationData: LocationData) {
Timber.d("## Location: zoomToLocation")
mapRefs?.map?.cameraPosition = CameraPosition.Builder()
.target(LatLng(latitude, longitude))
.zoom(INITIAL_MAP_ZOOM_IN_PREVIEW)
.build()
mapRefs?.map?.zoomToLocation(locationData)
}
fun getLocationOfMapCenter(): LocationData? =

View file

@ -0,0 +1,40 @@
/*
* 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.map
import androidx.lifecycle.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.mapLatest
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom
import javax.inject.Inject
class GetListOfUserLiveLocationUseCase @Inject constructor(
private val session: Session,
private val userLiveLocationViewStateMapper: UserLiveLocationViewStateMapper,
) {
fun execute(roomId: String): Flow<List<UserLiveLocationViewState>> {
return session.getRoom(roomId)
?.locationSharingService()
?.getRunningLiveLocationShareSummaries()
?.asFlow()
?.mapLatest { it.mapNotNull { summary -> userLiveLocationViewStateMapper.map(summary) } }
?: emptyFlow()
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.map
import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationLiveMapAction : VectorViewModelAction {
data class AddMapSymbol(val key: String, val value: Long) : LocationLiveMapAction()
data class RemoveMapSymbol(val key: String) : LocationLiveMapAction()
}

View file

@ -0,0 +1,21 @@
/*
* 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.map
import im.vector.app.core.platform.VectorViewEvents
sealed interface LocationLiveMapViewEvents : VectorViewEvents

View file

@ -16,48 +16,73 @@
package im.vector.app.features.location.live.map
import android.os.Bundle
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.geometry.LatLngBounds
import com.mapbox.mapboxsdk.maps.MapView
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.MapboxMapOptions
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.maps.SupportMapFragment
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.addChildFragment
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentSimpleContainerBinding
import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.zoomToBounds
import im.vector.app.features.location.zoomToLocation
import kotlinx.coroutines.launch
import timber.log.Timber
import java.lang.ref.WeakReference
import javax.inject.Inject
/**
* Screen showing a map with all the current users sharing their live location in room.
* Screen showing a map with all the current users sharing their live location in a room.
*/
@AndroidEntryPoint
class LocationLiveMapViewFragment : VectorBaseFragment<FragmentSimpleContainerBinding>() {
class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<FragmentSimpleContainerBinding>() {
@Inject
lateinit var urlMapProvider: UrlMapProvider
@Inject lateinit var urlMapProvider: UrlMapProvider
private val args: LocationLiveMapViewArgs by args()
private val viewModel: LocationLiveMapViewModel by fragmentViewModel()
private var mapboxMap: WeakReference<MapboxMap>? = null
private var symbolManager: SymbolManager? = null
private var mapStyle: Style? = null
private val pendingLiveLocations = mutableListOf<UserLiveLocationViewState>()
private var isMapFirstUpdate = true
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSimpleContainerBinding {
return FragmentSimpleContainerBinding.inflate(layoutInflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun onResume() {
super.onResume()
setupMap()
}
private fun setupMap() {
val mapFragment = getOrCreateSupportMapFragment()
mapFragment.getMapAsync { mapBoxMap ->
lifecycleScope.launchWhenCreated {
mapBoxMap.setStyle(urlMapProvider.getMapUrl())
mapFragment.getMapAsync { mapboxMap ->
lifecycleScope.launch {
mapboxMap.setStyle(urlMapProvider.getMapUrl()) { style ->
mapStyle = style
this@LocationLiveMapViewFragment.mapboxMap = WeakReference(mapboxMap)
symbolManager = SymbolManager(mapFragment.view as MapView, mapboxMap, style)
pendingLiveLocations
.takeUnless { it.isEmpty() }
?.let { updateMap(it) }
}
}
}
}
@ -70,6 +95,101 @@ class LocationLiveMapViewFragment : VectorBaseFragment<FragmentSimpleContainerBi
.also { addChildFragment(R.id.fragmentContainer, it, tag = MAP_FRAGMENT_TAG) }
}
override fun invalidate() = withState(viewModel) { viewState ->
updateMap(viewState.userLocations)
}
private fun updateMap(userLiveLocations: List<UserLiveLocationViewState>) {
symbolManager?.let { sManager ->
val latLngBoundsBuilder = LatLngBounds.Builder()
userLiveLocations.forEach { userLocation ->
createOrUpdateSymbol(userLocation, sManager)
if (isMapFirstUpdate) {
val latLng = LatLng(userLocation.locationData.latitude, userLocation.locationData.longitude)
latLngBoundsBuilder.include(latLng)
}
}
removeOutdatedSymbols(userLiveLocations, sManager)
updateMapZoomWhenNeeded(userLiveLocations, latLngBoundsBuilder)
} ?: postponeUpdateOfMap(userLiveLocations)
}
private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) = withState(viewModel) { state ->
val symbolId = state.mapSymbolIds[userLocation.userId]
if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
createSymbol(userLocation, symbolManager)
} else {
updateSymbol(symbolId, userLocation, symbolManager)
}
}
private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
addUserPinToMapStyle(userLocation.userId, userLocation.pinDrawable)
val symbolOptions = buildSymbolOptions(userLocation)
val symbol = symbolManager.create(symbolOptions)
viewModel.handle(LocationLiveMapAction.AddMapSymbol(userLocation.userId, symbol.id))
}
private fun updateSymbol(symbolId: Long, userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
val newLocation = LatLng(userLocation.locationData.latitude, userLocation.locationData.longitude)
val symbol = symbolManager.annotations.get(symbolId)
symbol?.let {
it.latLng = newLocation
symbolManager.update(it)
}
}
private fun removeOutdatedSymbols(userLiveLocations: List<UserLiveLocationViewState>, symbolManager: SymbolManager) = withState(viewModel) { state ->
val userIdsToRemove = state.mapSymbolIds.keys.subtract(userLiveLocations.map { it.userId }.toSet())
userIdsToRemove.forEach { userId ->
removeUserPinFromMapStyle(userId)
viewModel.handle(LocationLiveMapAction.RemoveMapSymbol(userId))
state.mapSymbolIds[userId]?.let { symbolId ->
Timber.d("trying to delete symbol with id: $symbolId")
symbolManager.annotations.get(symbolId)?.let {
symbolManager.delete(it)
}
}
}
}
private fun updateMapZoomWhenNeeded(userLiveLocations: List<UserLiveLocationViewState>, latLngBoundsBuilder: LatLngBounds.Builder) {
if (userLiveLocations.isNotEmpty() && isMapFirstUpdate) {
isMapFirstUpdate = false
if (userLiveLocations.size > 1) {
mapboxMap?.get()?.zoomToBounds(latLngBoundsBuilder.build())
} else {
mapboxMap?.get()?.zoomToLocation(userLiveLocations.first().locationData)
}
}
}
private fun postponeUpdateOfMap(userLiveLocations: List<UserLiveLocationViewState>) {
pendingLiveLocations.clear()
pendingLiveLocations.addAll(userLiveLocations)
}
private fun addUserPinToMapStyle(userId: String, userPinDrawable: Drawable) {
mapStyle?.let { style ->
if (style.getImage(userId) == null) {
style.addImage(userId, userPinDrawable.toBitmap())
}
}
}
private fun removeUserPinFromMapStyle(userId: String) {
mapStyle?.removeImage(userId)
}
private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) =
SymbolOptions()
.withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude))
.withIconImage(userLiveLocation.userId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
companion object {
private const val MAP_FRAGMENT_TAG = "im.vector.app.features.location.live.map"
}

View file

@ -0,0 +1,67 @@
/*
* 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.map
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class LocationLiveMapViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationLiveMapViewState,
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase
) : VectorViewModel<LocationLiveMapViewState, LocationLiveMapAction, LocationLiveMapViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationLiveMapViewModel, LocationLiveMapViewState> {
override fun create(initialState: LocationLiveMapViewState): LocationLiveMapViewModel
}
companion object : MavericksViewModelFactory<LocationLiveMapViewModel, LocationLiveMapViewState> by hiltMavericksViewModelFactory()
init {
getListOfUserLiveLocationUseCase.execute(initialState.roomId)
.onEach { setState { copy(userLocations = it) } }
.launchIn(viewModelScope)
}
override fun handle(action: LocationLiveMapAction) {
when (action) {
is LocationLiveMapAction.AddMapSymbol -> handleAddMapSymbol(action)
is LocationLiveMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
}
}
private fun handleAddMapSymbol(action: LocationLiveMapAction.AddMapSymbol) = withState { state ->
val newMapSymbolIds = state.mapSymbolIds.toMutableMap().apply { set(action.key, action.value) }
setState {
copy(mapSymbolIds = newMapSymbolIds)
}
}
private fun handleRemoveMapSymbol(action: LocationLiveMapAction.RemoveMapSymbol) = withState { state ->
val newMapSymbolIds = state.mapSymbolIds.toMutableMap().apply { remove(action.key) }
setState {
copy(mapSymbolIds = newMapSymbolIds)
}
}
}

View file

@ -0,0 +1,41 @@
/*
* 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.map
import android.graphics.drawable.Drawable
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.location.LocationData
data class LocationLiveMapViewState(
val roomId: String,
val userLocations: List<UserLiveLocationViewState> = emptyList(),
/**
* Map to keep track of symbol ids associated to each user Id.
*/
val mapSymbolIds: Map<String, Long> = emptyMap()
) : MavericksState {
constructor(locationLiveMapViewArgs: LocationLiveMapViewArgs) : this(
roomId = locationLiveMapViewArgs.roomId
)
}
data class UserLiveLocationViewState(
val userId: String,
val pinDrawable: Drawable,
val locationData: LocationData,
val endOfLiveTimestampMillis: Long?
)

View file

@ -0,0 +1,56 @@
/*
* 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.map
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.toLocationData
import kotlinx.coroutines.suspendCancellableCoroutine
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import javax.inject.Inject
class UserLiveLocationViewStateMapper @Inject constructor(
private val locationPinProvider: LocationPinProvider,
) {
suspend fun map(liveLocationShareAggregatedSummary: LiveLocationShareAggregatedSummary) =
suspendCancellableCoroutine<UserLiveLocationViewState?> { continuation ->
val userId = liveLocationShareAggregatedSummary.userId
val locationData = liveLocationShareAggregatedSummary.lastLocationDataContent
?.getBestLocationInfo()
?.geoUri
.toLocationData()
when {
userId.isNullOrEmpty() || locationData == null -> continuation.resume(null) {
// do nothing on cancellation
}
else -> {
locationPinProvider.create(userId) { pinDrawable ->
val viewState = UserLiveLocationViewState(
userId = userId,
pinDrawable = pinDrawable,
locationData = locationData,
endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis
)
continuation.resume(viewState) {
// do nothing on cancellation
}
}
}
}
}
}

View file

@ -0,0 +1,110 @@
/*
* 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.map
import androidx.lifecycle.asFlow
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.test.fakes.FakeSession
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.internal.assertEquals
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
class GetListOfUserLiveLocationUseCaseTest {
@get:Rule
val mvRxTestRule = MvRxTestRule()
private val fakeSession = FakeSession()
private val viewStateMapper = mockk<UserLiveLocationViewStateMapper>()
private val getListOfUserLiveLocationUseCase = GetListOfUserLiveLocationUseCase(fakeSession, viewStateMapper)
@Before
fun setUp() {
mockkStatic("androidx.lifecycle.FlowLiveDataConversions")
}
@After
fun tearDown() {
unmockkStatic("androidx.lifecycle.FlowLiveDataConversions")
}
@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,
endOfLiveTimestampMillis = 123,
lastLocationDataContent = MessageBeaconLocationDataContent()
)
val summary2 = LiveLocationShareAggregatedSummary(
userId = "userId2",
isActive = true,
endOfLiveTimestampMillis = 1234,
lastLocationDataContent = MessageBeaconLocationDataContent()
)
val summary3 = LiveLocationShareAggregatedSummary(
userId = "userId3",
isActive = true,
endOfLiveTimestampMillis = 1234,
lastLocationDataContent = MessageBeaconLocationDataContent()
)
val summaries = listOf(summary1, summary2, summary3)
val liveData = fakeSession.roomService()
.getRoom(roomId)
.locationSharingService()
.givenRunningLiveLocationShareSummaries(summaries)
every { liveData.asFlow() } returns flowOf(summaries)
val viewState1 = UserLiveLocationViewState(
userId = "userId1",
pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123
)
val viewState2 = UserLiveLocationViewState(
userId = "userId2",
pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 1234
)
coEvery { viewStateMapper.map(summary1) } returns viewState1
coEvery { viewStateMapper.map(summary2) } returns viewState2
coEvery { viewStateMapper.map(summary3) } returns null
val viewStates = getListOfUserLiveLocationUseCase.execute(roomId).first()
assertEquals(listOf(viewState1, viewState2), viewStates)
}
}

View file

@ -0,0 +1,70 @@
/*
* 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.map
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.test.test
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LocationLiveMapViewModelTest {
@get:Rule
val mvrxTestRule = MvRxTestRule()
private val fakeRoomId = ""
private val args = LocationLiveMapViewArgs(roomId = fakeRoomId)
private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
private fun createViewModel(): LocationLiveMapViewModel {
return LocationLiveMapViewModel(
LocationLiveMapViewState(args),
getListOfUserLiveLocationUseCase
)
}
@Test
fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest {
val userLocations = listOf(
UserLiveLocationViewState(
userId = "",
pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123
)
)
every { getListOfUserLiveLocationUseCase.execute(fakeRoomId) } returns flowOf(userLocations)
val viewModel = createViewModel()
viewModel
.test()
.assertState(
LocationLiveMapViewState(args).copy(
userLocations = userLocations
)
)
.finish()
}
}

View file

@ -0,0 +1,93 @@
/*
* 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.map
import android.graphics.drawable.Drawable
import im.vector.app.features.location.LocationData
import im.vector.app.test.fakes.FakeLocationPinProvider
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
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.LocationInfo
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
private const val A_USER_ID = "aUserId"
private const val A_IS_ACTIVE = true
private const val A_END_OF_LIVE_TIMESTAMP = 123L
private const val A_LATITUDE = 40.05
private const val A_LONGITUDE = 29.24
private const val A_UNCERTAINTY = 30.0
private const val A_GEO_URI = "geo:$A_LATITUDE,$A_LONGITUDE;$A_UNCERTAINTY"
class UserLiveLocationViewStateMapperTest {
private val locationPinProvider = FakeLocationPinProvider()
private val userLiveLocationViewStateMapper = UserLiveLocationViewStateMapper(locationPinProvider.instance)
@Test
fun `given a summary with invalid data then result is null`() = runTest {
val summary1 = LiveLocationShareAggregatedSummary(
userId = null,
isActive = true,
endOfLiveTimestampMillis = null,
lastLocationDataContent = null,
)
val summary2 = summary1.copy(userId = "")
val summaryWithoutLocation = summary1.copy(userId = A_USER_ID)
val viewState1 = userLiveLocationViewStateMapper.map(summary1)
val viewState2 = userLiveLocationViewStateMapper.map(summary2)
val viewState3 = userLiveLocationViewStateMapper.map(summaryWithoutLocation)
viewState1 shouldBeEqualTo null
viewState2 shouldBeEqualTo null
viewState3 shouldBeEqualTo null
}
@Test
fun `given a summary with valid data then result is correctly mapped`() = runTest {
val pinDrawable = mockk<Drawable>()
val locationDataContent = MessageBeaconLocationDataContent(
locationInfo = LocationInfo(geoUri = A_GEO_URI)
)
val summary = LiveLocationShareAggregatedSummary(
userId = A_USER_ID,
isActive = A_IS_ACTIVE,
endOfLiveTimestampMillis = A_END_OF_LIVE_TIMESTAMP,
lastLocationDataContent = locationDataContent,
)
locationPinProvider.givenCreateForUserId(A_USER_ID, pinDrawable)
val viewState = userLiveLocationViewStateMapper.map(summary)
val expectedViewState = UserLiveLocationViewState(
userId = A_USER_ID,
pinDrawable = pinDrawable,
locationData = LocationData(
latitude = A_LATITUDE,
longitude = A_LONGITUDE,
uncertainty = A_UNCERTAINTY
),
endOfLiveTimestampMillis = A_END_OF_LIVE_TIMESTAMP
)
viewState shouldBeEqualTo expectedViewState
}
}

View file

@ -0,0 +1,32 @@
/*
* 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 android.graphics.drawable.Drawable
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import io.mockk.every
import io.mockk.invoke
import io.mockk.mockk
class FakeLocationPinProvider {
val instance = mockk<LocationPinProvider>(relaxed = true)
fun givenCreateForUserId(userId: String, expectedDrawable: Drawable) {
every { instance.create(userId, captureLambda()) } answers { lambda<(Drawable) -> Unit>().invoke(expectedDrawable) }
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
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.model.livelocation.LiveLocationShareAggregatedSummary
class FakeLocationSharingService : LocationSharingService by mockk() {
fun givenRunningLiveLocationShareSummaries(summaries: List<LiveLocationShareAggregatedSummary>):
LiveData<List<LiveLocationShareAggregatedSummary>> {
return MutableLiveData(summaries).also {
every { getRunningLiveLocationShareSummaries() } returns it
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import io.mockk.mockk
import org.matrix.android.sdk.api.session.room.Room
class FakeRoom(
private val fakeLocationSharingService: FakeLocationSharingService = FakeLocationSharingService(),
) : Room by mockk() {
override fun locationSharingService() = fakeLocationSharingService
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import io.mockk.mockk
import org.matrix.android.sdk.api.session.room.RoomService
class FakeRoomService(
private val fakeRoom: FakeRoom = FakeRoom()
) : RoomService by mockk() {
override fun getRoom(roomId: String) = fakeRoom
}

View file

@ -33,7 +33,8 @@ class FakeSession(
val fakeCryptoService: FakeCryptoService = FakeCryptoService(),
val fakeProfileService: FakeProfileService = FakeProfileService(),
val fakeHomeServerCapabilitiesService: FakeHomeServerCapabilitiesService = FakeHomeServerCapabilitiesService(),
val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService()
val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService(),
private val fakeRoomService: FakeRoomService = FakeRoomService(),
) : Session by mockk(relaxed = true) {
init {
@ -48,6 +49,7 @@ class FakeSession(
override fun profileService(): ProfileService = fakeProfileService
override fun homeServerCapabilitiesService(): HomeServerCapabilitiesService = fakeHomeServerCapabilitiesService
override fun sharedSecretStorageService() = fakeSharedSecretStorageService
override fun roomService() = fakeRoomService
fun givenVectorStore(vectorSessionStore: VectorSessionStore) {
coEvery {