diff --git a/changelog.d/6012.wip b/changelog.d/6012.wip index 9c67d562fe..783ca6d46a 100644 --- a/changelog.d/6012.wip +++ b/changelog.d/6012.wip @@ -1 +1,2 @@ Live location sharing: navigation from timeline to map screen +Live location sharing: show user pins on map screen diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index 3a18cf1497..5d2769ac3c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -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 } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt new file mode 100644 index 0000000000..dd48d51f45 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt @@ -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> +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/LiveLocationShareAggregatedSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/LiveLocationShareAggregatedSummary.kt index 059fe21471..5ad1a48217 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/LiveLocationShareAggregatedSummary.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/LiveLocationShareAggregatedSummary.kt @@ -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. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 55bccfd1ec..592461f927 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -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() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt index c747ad334f..6bbeb17fdd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt @@ -58,7 +58,7 @@ internal object EventAnnotationsSummaryMapper { PollResponseAggregatedSummaryEntityMapper.map(it) }, liveLocationShareAggregatedSummary = annotationsSummary.liveLocationShareAggregatedSummary?.let { - LiveLocationShareAggregatedSummaryMapper.map(it) + LiveLocationShareAggregatedSummaryMapper().map(it) } ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt index 71b36f88bd..9460e4c6ba 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt @@ -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() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo029.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo029.kt new file mode 100644 index 0000000000..aebca11c2b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo029.kt @@ -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, "") + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/livelocation/LiveLocationShareAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/livelocation/LiveLocationShareAggregatedSummaryEntity.kt index e84337693f..c5df8e9338 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/livelocation/LiveLocationShareAggregatedSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/livelocation/LiveLocationShareAggregatedSummaryEntity.kt @@ -31,6 +31,8 @@ internal open class LiveLocationShareAggregatedSummaryEntity( var roomId: String = "", + var userId: String = "", + /** * Indicate whether the live is currently running. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt index 816b5f4392..0cc41413f6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt @@ -28,9 +28,15 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.where( roomId: String, eventId: String, ): RealmQuery { + return LiveLocationShareAggregatedSummaryEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, eventId) +} + +internal fun LiveLocationShareAggregatedSummaryEntity.Companion.whereRoomId(realm: Realm, + roomId: String): RealmQuery { return realm.where() .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 { + 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 { + return LiveLocationShareAggregatedSummaryEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true) + .isNotEmpty(LiveLocationShareAggregatedSummaryEntityFields.USER_ID) + .isNotNull(LiveLocationShareAggregatedSummaryEntityFields.LAST_LOCATION_CONTENT) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt index 7326adee4c..abea2d34cd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt @@ -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 } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt index 01c4fd1501..ffe7679575 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt @@ -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 ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt index 42dfc7ba9f..8f4682a9d5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt @@ -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 } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt new file mode 100644 index 0000000000..8cf6fcdfbf --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt @@ -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> { + return monarchy.findAllMappedWithChanges( + { LiveLocationShareAggregatedSummaryEntity.findRunningLiveInRoom(it, roomId = roomId) }, + { liveLocationShareAggregatedSummaryMapper.map(it) } + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapperTest.kt new file mode 100644 index 0000000000..47d5f46525 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapperTest.kt @@ -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) + ) +} diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 33afcf1dfb..313fd7b98c 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -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<*, *> } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt index cc5586e7f5..6de853519b 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt @@ -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) { diff --git a/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt b/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt new file mode 100644 index 0000000000..dbd2225909 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt @@ -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() + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt index 69e4e9fc20..dd2a56fb3a 100644 --- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt @@ -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? = diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCase.kt b/vector/src/main/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCase.kt new file mode 100644 index 0000000000..91f6999e2c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCase.kt @@ -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> { + return session.getRoom(roomId) + ?.locationSharingService() + ?.getRunningLiveLocationShareSummaries() + ?.asFlow() + ?.mapLatest { it.mapNotNull { summary -> userLiveLocationViewStateMapper.map(summary) } } + ?: emptyFlow() + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapAction.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapAction.kt new file mode 100644 index 0000000000..16cd3badc6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapAction.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewEvents.kt new file mode 100644 index 0000000000..6645ff58d9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewEvents.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt index 32b87727d8..946b6234c1 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt @@ -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() { +class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment() { - @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? = null + private var symbolManager: SymbolManager? = null + private var mapStyle: Style? = null + private val pendingLiveLocations = mutableListOf() + 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 + updateMap(viewState.userLocations) + } + + private fun updateMap(userLiveLocations: List) { + 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, 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, 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) { + 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" } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewModel.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewModel.kt new file mode 100644 index 0000000000..b14feea667 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewModel.kt @@ -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(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: LocationLiveMapViewState): LocationLiveMapViewModel + } + + companion object : MavericksViewModelFactory 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) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewState.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewState.kt new file mode 100644 index 0000000000..6f21f71e80 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewState.kt @@ -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 = emptyList(), + /** + * Map to keep track of symbol ids associated to each user Id. + */ + val mapSymbolIds: Map = emptyMap() +) : MavericksState { + constructor(locationLiveMapViewArgs: LocationLiveMapViewArgs) : this( + roomId = locationLiveMapViewArgs.roomId + ) +} + +data class UserLiveLocationViewState( + val userId: String, + val pinDrawable: Drawable, + val locationData: LocationData, + val endOfLiveTimestampMillis: Long? +) diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapper.kt b/vector/src/main/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapper.kt new file mode 100644 index 0000000000..8790144040 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapper.kt @@ -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 { 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 + } + } + } + } + } +} diff --git a/vector/src/test/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCaseTest.kt new file mode 100644 index 0000000000..765eee4937 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/location/live/map/GetListOfUserLiveLocationUseCaseTest.kt @@ -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() + + 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) + } +} diff --git a/vector/src/test/java/im/vector/app/features/location/live/map/LocationLiveMapViewModelTest.kt b/vector/src/test/java/im/vector/app/features/location/live/map/LocationLiveMapViewModelTest.kt new file mode 100644 index 0000000000..330cedf986 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/location/live/map/LocationLiveMapViewModelTest.kt @@ -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() + + 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() + } +} diff --git a/vector/src/test/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapperTest.kt b/vector/src/test/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapperTest.kt new file mode 100644 index 0000000000..cd20e0f12e --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapperTest.kt @@ -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() + + 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 + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationPinProvider.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationPinProvider.kt new file mode 100644 index 0000000000..726093215f --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationPinProvider.kt @@ -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(relaxed = true) + + fun givenCreateForUserId(userId: String, expectedDrawable: Drawable) { + every { instance.create(userId, captureLambda()) } answers { lambda<(Drawable) -> Unit>().invoke(expectedDrawable) } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingService.kt new file mode 100644 index 0000000000..2cd98c086c --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationSharingService.kt @@ -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): + LiveData> { + return MutableLiveData(summaries).also { + every { getRunningLiveLocationShareSummaries() } returns it + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt new file mode 100644 index 0000000000..ff87ab0fde --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt @@ -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 +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRoomService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomService.kt new file mode 100644 index 0000000000..b09256f747 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomService.kt @@ -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 +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt index 5f02879e65..cf94493f61 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt @@ -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 {