diff --git a/changelog.d/5689.wip b/changelog.d/5689.wip
new file mode 100644
index 0000000000..ccea1ec541
--- /dev/null
+++ b/changelog.d/5689.wip
@@ -0,0 +1 @@
+[Live location sharing] Update message in timeline during the live
diff --git a/library/ui-styles/src/main/res/values/styles_location.xml b/library/ui-styles/src/main/res/values/styles_location.xml
index 5563d28342..7571265241 100644
--- a/library/ui-styles/src/main/res/values/styles_location.xml
+++ b/library/ui-styles/src/main/res/values/styles_location.xml
@@ -2,10 +2,20 @@
+
+
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
index 16bdbd3432..7124d8a1a3 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
@@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
+import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
@@ -375,11 +376,11 @@ fun Event.getRelationContent(): RelationDefaultContent? {
content.toModel()?.relatesTo
} else {
content.toModel()?.relatesTo ?: run {
- // Special case to handle stickers, while there is only a local msgtype for stickers
- if (getClearType() == EventType.STICKER) {
- getClearContent().toModel()?.relatesTo
- } else {
- null
+ // Special cases when there is only a local msgtype for some event types
+ when (getClearType()) {
+ EventType.STICKER -> getClearContent().toModel()?.relatesTo
+ in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel()?.relatesTo
+ else -> null
}
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
index b87bc25435..d05fdb951f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
@@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
+import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
@@ -140,6 +141,7 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? {
EventType.STICKER -> root.getClearContent().toModel()
in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
+ in EventType.BEACON_LOCATION_DATA -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt
index 7ceb89e892..b5f49d7f9c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt
@@ -87,8 +87,6 @@ import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationMan
import org.matrix.android.sdk.internal.session.openid.DefaultOpenIdService
import org.matrix.android.sdk.internal.session.permalinks.DefaultPermalinkService
import org.matrix.android.sdk.internal.session.room.EventRelationsAggregationProcessor
-import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.DefaultLiveLocationAggregationProcessor
-import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor
import org.matrix.android.sdk.internal.session.room.create.RoomCreateEventProcessor
import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcessor
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
@@ -387,7 +385,4 @@ internal abstract class SessionModule {
@Binds
abstract fun bindEventSenderProcessor(processor: EventSenderProcessorCoroutine): EventSenderProcessor
-
- @Binds
- abstract fun bindLiveLocationAggregationProcessor(processor: DefaultLiveLocationAggregationProcessor): LiveLocationAggregationProcessor
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
index 16a63a9a96..af9c0071fe 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
@@ -193,9 +193,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
}
in EventType.BEACON_LOCATION_DATA -> {
- event.getClearContent().toModel(catchError = true)?.let {
- liveLocationAggregationProcessor.handleBeaconLocationData(realm, event, it, roomId, isLocalEcho)
- }
+ handleBeaconLocationData(event, realm, roomId, isLocalEcho)
}
}
} else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) {
@@ -260,6 +258,9 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
liveLocationAggregationProcessor.handleBeaconInfo(realm, event, it, roomId, isLocalEcho)
}
}
+ in EventType.BEACON_LOCATION_DATA -> {
+ handleBeaconLocationData(event, realm, roomId, isLocalEcho)
+ }
else -> Timber.v("UnHandled event ${event.eventId}")
}
} catch (t: Throwable) {
@@ -756,4 +757,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
verifSummary.sourceEvents.add(event.eventId)
}
}
+
+ private fun handleBeaconLocationData(event: Event, realm: Realm, roomId: String, isLocalEcho: Boolean) {
+ event.getClearContent().toModel(catchError = true)?.let {
+ liveLocationAggregationProcessor.handleBeaconLocationData(
+ realm,
+ event,
+ it,
+ roomId,
+ event.getRelationContent()?.eventId,
+ isLocalEcho
+ )
+ }
+ }
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/DefaultLiveLocationAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/DefaultLiveLocationAggregationProcessor.kt
deleted file mode 100644
index 997e31a109..0000000000
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/DefaultLiveLocationAggregationProcessor.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright 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.aggregation.livelocation
-
-import io.realm.Realm
-import org.matrix.android.sdk.api.extensions.orTrue
-import org.matrix.android.sdk.api.session.events.model.Event
-import org.matrix.android.sdk.api.session.events.model.toContent
-import org.matrix.android.sdk.api.session.events.model.toModel
-import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
-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.getOrCreate
-import timber.log.Timber
-import javax.inject.Inject
-
-internal class DefaultLiveLocationAggregationProcessor @Inject constructor() : LiveLocationAggregationProcessor {
-
- override fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) {
- if (event.senderId.isNullOrEmpty() || isLocalEcho) {
- return
- }
-
- val targetEventId = if (content.isLive.orTrue()) {
- event.eventId
- } else {
- // when live is set to false, we use the id of the event that should have been replaced
- event.unsignedData?.replacesState
- }
-
- if (targetEventId.isNullOrEmpty()) {
- Timber.w("no target event id found for the beacon content")
- return
- }
-
- val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
- realm = realm,
- roomId = roomId,
- eventId = targetEventId
- )
-
- Timber.d("updating summary of id=$targetEventId with isLive=${content.isLive}")
-
- aggregatedSummary.endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) }
- aggregatedSummary.isActive = content.isLive
- }
-
- override fun handleBeaconLocationData(realm: Realm, event: Event, content: MessageBeaconLocationDataContent, roomId: String, isLocalEcho: Boolean) {
- if (event.senderId.isNullOrEmpty() || isLocalEcho) {
- return
- }
-
- val targetEventId = content.relatesTo?.eventId
-
- if (targetEventId.isNullOrEmpty()) {
- Timber.w("no target event id found for the live location content")
- return
- }
-
- val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
- realm = realm,
- roomId = roomId,
- eventId = targetEventId
- )
- val updatedLocationTimestamp = content.getBestTimestampMillis() ?: 0
- val currentLocationTimestamp = ContentMapper
- .map(aggregatedSummary.lastLocationContent)
- .toModel()
- ?.getBestTimestampMillis()
- ?: 0
-
- if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) {
- Timber.d("updating last location of the summary of id=$targetEventId")
- aggregatedSummary.lastLocationContent = ContentMapper.map(content.toContent())
- }
- }
-
- private fun Long.isMoreRecentThan(timestamp: Long) = this > timestamp
-}
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 c0be96f83d..76b7a4ec8e 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
@@ -17,24 +17,83 @@
package org.matrix.android.sdk.internal.session.room.aggregation.livelocation
import io.realm.Realm
+import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.events.model.toContent
+import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
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.getOrCreate
+import timber.log.Timber
+import javax.inject.Inject
-internal interface LiveLocationAggregationProcessor {
- fun handleBeaconInfo(
- realm: Realm,
- event: Event,
- content: MessageBeaconInfoContent,
- roomId: String,
- isLocalEcho: Boolean,
- )
+internal class LiveLocationAggregationProcessor @Inject constructor() {
+
+ fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) {
+ if (event.senderId.isNullOrEmpty() || isLocalEcho) {
+ return
+ }
+
+ val targetEventId = if (content.isLive.orTrue()) {
+ event.eventId
+ } else {
+ // when live is set to false, we use the id of the event that should have been replaced
+ event.unsignedData?.replacesState
+ }
+
+ if (targetEventId.isNullOrEmpty()) {
+ Timber.w("no target event id found for the beacon content")
+ return
+ }
+
+ val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
+ realm = realm,
+ roomId = roomId,
+ eventId = targetEventId
+ )
+
+ Timber.d("updating summary of id=$targetEventId with isLive=${content.isLive}")
+
+ aggregatedSummary.endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) }
+ aggregatedSummary.isActive = content.isLive
+ }
fun handleBeaconLocationData(
realm: Realm,
event: Event,
content: MessageBeaconLocationDataContent,
roomId: String,
- isLocalEcho: Boolean,
- )
+ relatedEventId: String?,
+ isLocalEcho: Boolean
+ ) {
+ if (event.senderId.isNullOrEmpty() || isLocalEcho) {
+ return
+ }
+
+ if (relatedEventId.isNullOrEmpty()) {
+ Timber.w("no related event id found for the live location content")
+ return
+ }
+
+ val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
+ realm = realm,
+ roomId = roomId,
+ eventId = relatedEventId
+ )
+ val updatedLocationTimestamp = content.getBestTimestampMillis() ?: 0
+ val currentLocationTimestamp = ContentMapper
+ .map(aggregatedSummary.lastLocationContent)
+ .toModel()
+ ?.getBestTimestampMillis()
+ ?: 0
+
+ if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) {
+ Timber.d("updating last location of the summary of id=$relatedEventId")
+ aggregatedSummary.lastLocationContent = ContentMapper.map(content.toContent())
+ }
+ }
+
+ private fun Long.isMoreRecentThan(timestamp: Long) = this > timestamp
}
diff --git a/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt b/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt
index 30cb1dcae4..6762bd68da 100644
--- a/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt
+++ b/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt
@@ -19,27 +19,30 @@ package im.vector.app.core.resources
import org.threeten.bp.Instant
import org.threeten.bp.LocalDateTime
import org.threeten.bp.ZoneId
+import org.threeten.bp.ZoneOffset
object DateProvider {
- private val zoneId = ZoneId.systemDefault()
- private val zoneOffset by lazy {
- val now = currentLocalDateTime()
- zoneId.rules.getOffset(now)
- }
+ // recompute the zoneId each time we access it to handle change of timezones
+ private val defaultZoneId: ZoneId
+ get() = ZoneId.systemDefault()
+
+ // recompute the zoneOffset each time we access it to handle change of timezones
+ private val defaultZoneOffset: ZoneOffset
+ get() = defaultZoneId.rules.getOffset(currentLocalDateTime())
fun toLocalDateTime(timestamp: Long?): LocalDateTime {
val instant = Instant.ofEpochMilli(timestamp ?: 0)
- return LocalDateTime.ofInstant(instant, zoneId)
+ return LocalDateTime.ofInstant(instant, defaultZoneId)
}
fun currentLocalDateTime(): LocalDateTime {
val instant = Instant.now()
- return LocalDateTime.ofInstant(instant, zoneId)
+ return LocalDateTime.ofInstant(instant, defaultZoneId)
}
fun toTimestamp(localDateTime: LocalDateTime): Long {
- return localDateTime.toInstant(zoneOffset).toEpochMilli()
+ return localDateTime.toInstant(defaultZoneOffset).toEpochMilli()
}
}
diff --git a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt
index 992a85679c..d2f8c4022b 100644
--- a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt
+++ b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt
@@ -19,11 +19,15 @@ package im.vector.app.core.utils
import android.content.Context
import android.os.Build
import android.text.format.Formatter
+import im.vector.app.R
import org.threeten.bp.Duration
import java.util.TreeMap
object TextUtils {
+ private const val MINUTES_PER_HOUR = 60
+ private const val SECONDS_PER_MINUTE = 60
+
private val suffixes = TreeMap().also {
it[1000] = "k"
it[1000000] = "M"
@@ -71,13 +75,63 @@ object TextUtils {
}
fun formatDuration(duration: Duration): String {
- val hours = duration.seconds / 3600
- val minutes = (duration.seconds % 3600) / 60
- val seconds = duration.seconds % 60
+ val hours = getHours(duration)
+ val minutes = getMinutes(duration)
+ val seconds = getSeconds(duration)
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
+
+ fun formatDurationWithUnits(context: Context, duration: Duration): String {
+ val hours = getHours(duration)
+ val minutes = getMinutes(duration)
+ val seconds = getSeconds(duration)
+ val builder = StringBuilder()
+ when {
+ hours > 0 -> {
+ appendHours(context, builder, hours)
+ if (minutes > 0) {
+ builder.append(" ")
+ appendMinutes(context, builder, minutes)
+ }
+ if (seconds > 0) {
+ builder.append(" ")
+ appendSeconds(context, builder, seconds)
+ }
+ }
+ minutes > 0 -> {
+ appendMinutes(context, builder, minutes)
+ if (seconds > 0) {
+ builder.append(" ")
+ appendSeconds(context, builder, seconds)
+ }
+ }
+ else -> {
+ appendSeconds(context, builder, seconds)
+ }
+ }
+ return builder.toString()
+ }
+
+ private fun appendHours(context: Context, builder: StringBuilder, hours: Int) {
+ builder.append(hours)
+ builder.append(context.resources.getString(R.string.time_unit_hour_short))
+ }
+
+ private fun appendMinutes(context: Context, builder: StringBuilder, minutes: Int) {
+ builder.append(minutes)
+ builder.append(context.getString(R.string.time_unit_minute_short))
+ }
+
+ private fun appendSeconds(context: Context, builder: StringBuilder, seconds: Int) {
+ builder.append(seconds)
+ builder.append(context.getString(R.string.time_unit_second_short))
+ }
+
+ private fun getHours(duration: Duration): Int = duration.toHours().toInt()
+ private fun getMinutes(duration: Duration): Int = duration.toMinutes().toInt() % MINUTES_PER_HOUR
+ private fun getSeconds(duration: Duration): Int = (duration.seconds % SECONDS_PER_MINUTE).toInt()
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationMessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationMessageItemFactory.kt
deleted file mode 100644
index d233deffb8..0000000000
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationMessageItemFactory.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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.home.room.detail.timeline.factory
-
-import im.vector.app.core.epoxy.VectorEpoxyModel
-import im.vector.app.core.utils.DimensionConverter
-import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
-import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
-import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
-import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem
-import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem_
-import org.matrix.android.sdk.api.extensions.orFalse
-import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
-import javax.inject.Inject
-
-class LiveLocationMessageItemFactory @Inject constructor(
- private val dimensionConverter: DimensionConverter,
- private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
- private val avatarSizeProvider: AvatarSizeProvider,
-) {
-
- fun create(
- beaconInfoContent: MessageBeaconInfoContent,
- highlight: Boolean,
- attributes: AbsMessageItem.Attributes,
- ): VectorEpoxyModel<*>? {
- // TODO handle location received and stopped states
- return when {
- isLiveRunning(beaconInfoContent) -> buildStartLiveItem(highlight, attributes)
- else -> null
- }
- }
-
- private fun isLiveRunning(beaconInfoContent: MessageBeaconInfoContent): Boolean {
- // TODO when we will use aggregatedSummary, check if the live has timed out as well
- return beaconInfoContent.isLive.orFalse()
- }
-
- private fun buildStartLiveItem(
- highlight: Boolean,
- attributes: AbsMessageItem.Attributes,
- ): MessageLiveLocationStartItem {
- val width = timelineMediaSizeProvider.getMaxSize().first
- val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
-
- return MessageLiveLocationStartItem_()
- .attributes(attributes)
- .mapWidth(width)
- .mapHeight(height)
- .highlighted(highlight)
- .leftGuideline(avatarSizeProvider.leftGuideline)
- }
-}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt
new file mode 100644
index 0000000000..479a742369
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt
@@ -0,0 +1,176 @@
+/*
+ * 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.home.room.detail.timeline.factory
+
+import im.vector.app.core.date.VectorDateFormatter
+import im.vector.app.core.epoxy.VectorEpoxyModel
+import im.vector.app.core.resources.DateProvider
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
+import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
+import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
+import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
+import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationInactiveItem
+import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationInactiveItem_
+import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationItem
+import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationItem_
+import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem
+import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem_
+import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE
+import im.vector.app.features.location.UrlMapProvider
+import im.vector.app.features.location.toLocationData
+import org.matrix.android.sdk.api.extensions.orFalse
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import org.threeten.bp.LocalDateTime
+import timber.log.Timber
+import javax.inject.Inject
+
+class LiveLocationShareMessageItemFactory @Inject constructor(
+ private val session: Session,
+ private val dimensionConverter: DimensionConverter,
+ private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
+ private val avatarSizeProvider: AvatarSizeProvider,
+ private val urlMapProvider: UrlMapProvider,
+ private val locationPinProvider: LocationPinProvider,
+ private val vectorDateFormatter: VectorDateFormatter,
+) {
+
+ fun create(
+ event: TimelineEvent,
+ highlight: Boolean,
+ attributes: AbsMessageItem.Attributes,
+ ): VectorEpoxyModel<*>? {
+ val liveLocationShareSummaryData = getLiveLocationShareSummaryData(event)
+ val item = when (val currentState = getViewState(liveLocationShareSummaryData)) {
+ LiveLocationShareViewState.Inactive -> buildInactiveItem(highlight, attributes)
+ LiveLocationShareViewState.Loading -> buildLoadingItem(highlight, attributes)
+ is LiveLocationShareViewState.Running -> buildRunningItem(highlight, attributes, currentState)
+ LiveLocationShareViewState.Unkwown -> null
+ }
+ item?.layout(attributes.informationData.messageLayout.layoutRes)
+
+ return item
+ }
+
+ private fun buildInactiveItem(
+ highlight: Boolean,
+ attributes: AbsMessageItem.Attributes,
+ ): MessageLiveLocationInactiveItem {
+ val width = timelineMediaSizeProvider.getMaxSize().first
+ val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
+
+ return MessageLiveLocationInactiveItem_()
+ .attributes(attributes)
+ .mapWidth(width)
+ .mapHeight(height)
+ .highlighted(highlight)
+ .leftGuideline(avatarSizeProvider.leftGuideline)
+ }
+
+ private fun buildLoadingItem(
+ highlight: Boolean,
+ attributes: AbsMessageItem.Attributes,
+ ): MessageLiveLocationStartItem {
+ val width = timelineMediaSizeProvider.getMaxSize().first
+ val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
+
+ return MessageLiveLocationStartItem_()
+ .attributes(attributes)
+ .mapWidth(width)
+ .mapHeight(height)
+ .highlighted(highlight)
+ .leftGuideline(avatarSizeProvider.leftGuideline)
+ }
+
+ private fun buildRunningItem(
+ highlight: Boolean,
+ attributes: AbsMessageItem.Attributes,
+ runningState: LiveLocationShareViewState.Running,
+ ): MessageLiveLocationItem {
+ // TODO only render location if enabled in preferences: to be handled in a next PR
+ val width = timelineMediaSizeProvider.getMaxSize().first
+ val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
+
+ val locationUrl = runningState.lastGeoUri.toLocationData()?.let {
+ urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height)
+ }
+
+ return MessageLiveLocationItem_()
+ .attributes(attributes)
+ .locationUrl(locationUrl)
+ .mapWidth(width)
+ .mapHeight(height)
+ .locationUserId(attributes.informationData.senderId)
+ .locationPinProvider(locationPinProvider)
+ .highlighted(highlight)
+ .leftGuideline(avatarSizeProvider.leftGuideline)
+ .currentUserId(session.myUserId)
+ .endOfLiveDateTime(runningState.endOfLiveDateTime)
+ .vectorDateFormatter(vectorDateFormatter)
+ }
+
+ private fun getViewState(liveLocationShareSummaryData: LiveLocationShareSummaryData?): LiveLocationShareViewState {
+ return when {
+ liveLocationShareSummaryData?.isActive == null -> LiveLocationShareViewState.Unkwown
+ liveLocationShareSummaryData.isActive.not() || isLiveTimedOut(liveLocationShareSummaryData) -> LiveLocationShareViewState.Inactive
+ liveLocationShareSummaryData.isActive && liveLocationShareSummaryData.lastGeoUri.isNullOrEmpty() -> LiveLocationShareViewState.Loading
+ else ->
+ LiveLocationShareViewState.Running(
+ liveLocationShareSummaryData.lastGeoUri.orEmpty(),
+ getEndOfLiveDateTime(liveLocationShareSummaryData)
+ )
+ }.also { viewState -> Timber.d("computed viewState: $viewState") }
+ }
+
+ private fun isLiveTimedOut(liveLocationShareSummaryData: LiveLocationShareSummaryData): Boolean {
+ return getEndOfLiveDateTime(liveLocationShareSummaryData)
+ ?.let { endOfLive ->
+ // this will only cover users with different timezones but not users with manually time set
+ val now = LocalDateTime.now()
+ now.isAfter(endOfLive)
+ }
+ .orFalse()
+ }
+
+ private fun getEndOfLiveDateTime(liveLocationShareSummaryData: LiveLocationShareSummaryData): LocalDateTime? {
+ return liveLocationShareSummaryData.endOfLiveTimestampMillis?.let { DateProvider.toLocalDateTime(timestamp = it) }
+ }
+
+ private fun getLiveLocationShareSummaryData(event: TimelineEvent): LiveLocationShareSummaryData? {
+ return event.annotations?.liveLocationShareAggregatedSummary?.let { summary ->
+ LiveLocationShareSummaryData(
+ isActive = summary.isActive,
+ endOfLiveTimestampMillis = summary.endOfLiveTimestampMillis,
+ lastGeoUri = summary.lastLocationDataContent?.getBestLocationInfo()?.geoUri
+ )
+ }
+ }
+
+ private data class LiveLocationShareSummaryData(
+ val isActive: Boolean?,
+ val endOfLiveTimestampMillis: Long?,
+ val lastGeoUri: String?,
+ )
+
+ private sealed class LiveLocationShareViewState {
+ object Loading : LiveLocationShareViewState()
+ data class Running(val lastGeoUri: String, val endOfLiveDateTime: LocalDateTime?) : LiveLocationShareViewState()
+ object Inactive : LiveLocationShareViewState()
+ object Unkwown : LiveLocationShareViewState()
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
index b960e2c6a9..13f783cded 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
@@ -148,7 +148,7 @@ class MessageItemFactory @Inject constructor(
private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider,
- private val liveLocationMessageItemFactory: LiveLocationMessageItemFactory,
+ private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory,
) {
// TODO inject this properly?
@@ -216,7 +216,7 @@ class MessageItemFactory @Inject constructor(
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
}
}
- is MessageBeaconInfoContent -> liveLocationMessageItemFactory.create(messageContent, highlight, attributes)
+ is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes)
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
}
return messageItem?.apply {
@@ -237,14 +237,14 @@ class MessageItemFactory @Inject constructor(
urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height)
}
- val userId = if (locationContent.isSelfLocation()) informationData.senderId else null
+ val locationUserId = if (locationContent.isSelfLocation()) informationData.senderId else null
return MessageLocationItem_()
.attributes(attributes)
.locationUrl(locationUrl)
.mapWidth(width)
.mapHeight(height)
- .userId(userId)
+ .locationUserId(locationUserId)
.locationPinProvider(locationPinProvider)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
index f4bcc1ba65..07ae9d66c3 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
@@ -100,7 +100,7 @@ class TimelineItemFactory @Inject constructor(
// Message itemsX
EventType.STICKER,
in EventType.POLL_START,
- EventType.MESSAGE -> messageItemFactory.create(params)
+ EventType.MESSAGE -> messageItemFactory.create(params)
EventType.REDACTION,
EventType.KEY_VERIFICATION_ACCEPT,
EventType.KEY_VERIFICATION_START,
@@ -113,14 +113,15 @@ class TimelineItemFactory @Inject constructor(
EventType.CALL_NEGOTIATE,
EventType.REACTION,
in EventType.POLL_RESPONSE,
- in EventType.POLL_END -> noticeItemFactory.create(params)
+ in EventType.POLL_END,
+ in EventType.BEACON_LOCATION_DATA -> noticeItemFactory.create(params)
// Calls
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_REJECT,
- EventType.CALL_ANSWER -> callItemFactory.create(params)
+ EventType.CALL_ANSWER -> callItemFactory.create(params)
// Crypto
- EventType.ENCRYPTED -> {
+ EventType.ENCRYPTED -> {
if (event.root.isRedacted()) {
// Redacted event, let the MessageItemFactory handle it
messageItemFactory.create(params)
@@ -129,11 +130,11 @@ class TimelineItemFactory @Inject constructor(
}
}
EventType.KEY_VERIFICATION_CANCEL,
- EventType.KEY_VERIFICATION_DONE -> {
+ EventType.KEY_VERIFICATION_DONE -> {
verificationConclusionItemFactory.create(params)
}
// Unhandled event types
- else -> {
+ else -> {
// Should only happen when shouldShowHiddenEvents() settings is ON
Timber.v("Type ${event.root.getClearType()} not handled")
defaultItemFactory.create(params)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
index 7ad0cb27c6..8e06b3ee5d 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
@@ -107,7 +107,8 @@ class NoticeEventFormatter @Inject constructor(
EventType.REDACTION,
EventType.STICKER,
in EventType.POLL_RESPONSE,
- in EventType.POLL_END -> formatDebug(timelineEvent.root)
+ in EventType.POLL_END,
+ in EventType.BEACON_LOCATION_DATA -> formatDebug(timelineEvent.root)
else -> {
Timber.v("Type $type not handled by this formatter")
null
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt
index 1e95f067d2..7874f843e1 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt
@@ -44,8 +44,7 @@ import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited
import javax.inject.Inject
/**
- * This class compute if data of an event (such has avatar, display name, ...) should be displayed, depending on the previous event in the timeline.
- * TODO Update this comment
+ * This class is responsible of building extra information data associated to a given event.
*/
class MessageInformationDataFactory @Inject constructor(private val session: Session,
private val dateFormatter: VectorDateFormatter,
@@ -119,7 +118,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
isFirstFromThisSender = isFirstFromThisSender,
isLastFromThisSender = isLastFromThisSender,
e2eDecoration = e2eDecoration,
- sendStateDecoration = sendStateDecoration
+ sendStateDecoration = sendStateDecoration,
)
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt
index 45c711ff93..737b0dc85d 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt
@@ -57,6 +57,7 @@ class MessageItemAttributesFactory @Inject constructor(
memberClickListener = {
callback?.onMemberNameClicked(informationData)
},
+ callback = callback,
reactionPillCallback = callback,
avatarCallback = callback,
threadCallback = callback,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt
index 263f105b6b..b9d79d5818 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt
@@ -178,6 +178,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem
override val itemLongClickListener: View.OnLongClickListener? = null,
override val itemClickListener: ClickListener? = null,
val memberClickListener: ClickListener? = null,
+ val callback: TimelineEventController.Callback? = null,
override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null,
val avatarCallback: TimelineEventController.AvatarCallback? = null,
val threadCallback: TimelineEventController.ThreadCallback? = null,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageLocationItem.kt
new file mode 100644
index 0000000000..f7146c24e9
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageLocationItem.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.home.room.detail.timeline.item
+
+import android.graphics.drawable.Drawable
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.IdRes
+import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
+import com.airbnb.epoxy.EpoxyAttribute
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.RequestOptions
+import com.bumptech.glide.request.target.Target
+import im.vector.app.R
+import im.vector.app.core.glide.GlideApp
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
+import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
+import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
+
+abstract class AbsMessageLocationItem : AbsMessageItem() {
+
+ @EpoxyAttribute
+ var locationUrl: String? = null
+
+ @EpoxyAttribute
+ var locationUserId: String? = null
+
+ @EpoxyAttribute
+ var mapWidth: Int = 0
+
+ @EpoxyAttribute
+ var mapHeight: Int = 0
+
+ @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
+ var locationPinProvider: LocationPinProvider? = null
+
+ override fun bind(holder: H) {
+ super.bind(holder)
+ renderSendState(holder.view, null)
+ bindMap(holder)
+ }
+
+ private fun bindMap(holder: Holder) {
+ val location = locationUrl ?: return
+ val messageLayout = attributes.informationData.messageLayout
+ val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
+ messageLayout.cornersRadius.granularRoundedCorners()
+ } else {
+ val dimensionConverter = DimensionConverter(holder.view.resources)
+ RoundedCorners(dimensionConverter.dpToPx(8))
+ }
+ holder.staticMapImageView.updateLayoutParams {
+ width = mapWidth
+ height = mapHeight
+ }
+ GlideApp.with(holder.staticMapImageView)
+ .load(location)
+ .apply(RequestOptions.centerCropTransform())
+ .listener(object : RequestListener {
+ override fun onLoadFailed(e: GlideException?,
+ model: Any?,
+ target: Target?,
+ isFirstResource: Boolean): Boolean {
+ holder.staticMapPinImageView.setImageResource(R.drawable.ic_location_pin_failed)
+ holder.staticMapErrorTextView.isVisible = true
+ return false
+ }
+
+ override fun onResourceReady(resource: Drawable?,
+ model: Any?,
+ target: Target?,
+ dataSource: DataSource?,
+ isFirstResource: Boolean): Boolean {
+ locationPinProvider?.create(locationUserId) { pinDrawable ->
+ // we are not using Glide since it does not display it correctly when there is no user photo
+ holder.staticMapPinImageView.setImageDrawable(pinDrawable)
+ }
+ holder.staticMapErrorTextView.isVisible = false
+ return false
+ }
+ })
+ .transform(imageCornerTransformation)
+ .into(holder.staticMapImageView)
+ }
+
+ abstract class Holder(@IdRes stubId: Int) : AbsMessageItem.Holder(stubId) {
+ val staticMapImageView by bind(R.id.staticMapImageView)
+ val staticMapPinImageView by bind(R.id.staticMapPinImageView)
+ val staticMapErrorTextView by bind(R.id.staticMapErrorTextView)
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt
new file mode 100644
index 0000000000..c421efda12
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.home.room.detail.timeline.item
+
+import android.content.res.Resources
+import android.graphics.drawable.ColorDrawable
+import android.widget.ImageView
+import androidx.core.view.updateLayoutParams
+import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners
+import im.vector.app.R
+import im.vector.app.core.glide.GlideApp
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
+import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
+import im.vector.app.features.themes.ThemeUtils
+
+/**
+ * Default implementation of common methods for item representing the status of a live location share.
+ */
+class DefaultLiveLocationShareStatusItem : LiveLocationShareStatusItem {
+
+ override fun bindMap(
+ mapImageView: ImageView,
+ mapWidth: Int,
+ mapHeight: Int,
+ messageLayout: TimelineMessageLayout
+ ) {
+ val mapCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
+ messageLayout.cornersRadius.granularRoundedCorners()
+ } else {
+ RoundedCorners(getDefaultLayoutCornerRadiusInDp(mapImageView.resources))
+ }
+ mapImageView.updateLayoutParams {
+ width = mapWidth
+ height = mapHeight
+ }
+ GlideApp.with(mapImageView)
+ .load(R.drawable.bg_no_location_map)
+ .transform(mapCornerTransformation)
+ .into(mapImageView)
+ }
+
+ override fun bindBottomBanner(bannerImageView: ImageView, messageLayout: TimelineMessageLayout) {
+ val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
+ GranularRoundedCorners(
+ 0f,
+ 0f,
+ messageLayout.cornersRadius.bottomEndRadius,
+ messageLayout.cornersRadius.bottomStartRadius
+ )
+ } else {
+ val bottomCornerRadius = getDefaultLayoutCornerRadiusInDp(bannerImageView.resources).toFloat()
+ GranularRoundedCorners(0f, 0f, bottomCornerRadius, bottomCornerRadius)
+ }
+ GlideApp.with(bannerImageView)
+ .load(ColorDrawable(ThemeUtils.getColor(bannerImageView.context, android.R.attr.colorBackground)))
+ .transform(imageCornerTransformation)
+ .into(bannerImageView)
+ }
+
+ private fun getDefaultLayoutCornerRadiusInDp(resources: Resources): Int {
+ val dimensionConverter = DimensionConverter(resources)
+ return dimensionConverter.dpToPx(8)
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/LiveLocationShareStatusItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/LiveLocationShareStatusItem.kt
new file mode 100644
index 0000000000..2f79f2fc9e
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/LiveLocationShareStatusItem.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.home.room.detail.timeline.item
+
+import android.widget.ImageView
+import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
+
+interface LiveLocationShareStatusItem {
+ fun bindMap(
+ mapImageView: ImageView,
+ mapWidth: Int,
+ mapHeight: Int,
+ messageLayout: TimelineMessageLayout
+ )
+
+ fun bindBottomBanner(bannerImageView: ImageView, messageLayout: TimelineMessageLayout)
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt
index 9620077fd8..258424c7de 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt
@@ -42,7 +42,7 @@ data class MessageInformationData(
val e2eDecoration: E2EDecoration = E2EDecoration.NONE,
val sendStateDecoration: SendStateDecoration = SendStateDecoration.NONE,
val isFirstFromThisSender: Boolean = false,
- val isLastFromThisSender: Boolean = false
+ val isLastFromThisSender: Boolean = false,
) : Parcelable {
val matrixItem: MatrixItem
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt
new file mode 100644
index 0000000000..bb85316bf1
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.home.room.detail.timeline.item
+
+import android.widget.ImageView
+import com.airbnb.epoxy.EpoxyAttribute
+import com.airbnb.epoxy.EpoxyModelClass
+import im.vector.app.R
+
+@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
+abstract class MessageLiveLocationInactiveItem :
+ AbsMessageItem(),
+ LiveLocationShareStatusItem by DefaultLiveLocationShareStatusItem() {
+
+ @EpoxyAttribute
+ var mapWidth: Int = 0
+
+ @EpoxyAttribute
+ var mapHeight: Int = 0
+
+ override fun bind(holder: Holder) {
+ super.bind(holder)
+ renderSendState(holder.view, null)
+ bindMap(holder.noLocationMapImageView, mapWidth, mapHeight, attributes.informationData.messageLayout)
+ bindBottomBanner(holder.bannerImageView, attributes.informationData.messageLayout)
+ }
+
+ override fun getViewStubId() = STUB_ID
+
+ class Holder : AbsMessageItem.Holder(STUB_ID) {
+ val bannerImageView by bind(R.id.locationLiveInactiveBanner)
+ val noLocationMapImageView by bind(R.id.locationLiveInactiveMap)
+ }
+
+ companion object {
+ private const val STUB_ID = R.id.messageContentLiveLocationInactiveStub
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt
new file mode 100644
index 0000000000..838fbd46de
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt
@@ -0,0 +1,121 @@
+/*
+ * 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.home.room.detail.timeline.item
+
+import androidx.core.view.isVisible
+import com.airbnb.epoxy.EpoxyAttribute
+import com.airbnb.epoxy.EpoxyModelClass
+import im.vector.app.R
+import im.vector.app.core.date.DateFormatKind
+import im.vector.app.core.date.VectorDateFormatter
+import im.vector.app.core.resources.toTimestamp
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.features.home.room.detail.RoomDetailAction
+import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
+import im.vector.app.features.location.live.LocationLiveMessageBannerView
+import im.vector.app.features.location.live.LocationLiveMessageBannerViewState
+import org.threeten.bp.LocalDateTime
+
+@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
+abstract class MessageLiveLocationItem : AbsMessageLocationItem() {
+
+ @EpoxyAttribute
+ var currentUserId: String? = null
+
+ @EpoxyAttribute
+ var endOfLiveDateTime: LocalDateTime? = null
+
+ @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
+ lateinit var vectorDateFormatter: VectorDateFormatter
+
+ override fun bind(holder: Holder) {
+ super.bind(holder)
+ bindLocationLiveBanner(holder)
+ }
+
+ private fun bindLocationLiveBanner(holder: Holder) {
+ // TODO in a future PR add check on device id to confirm that is the one that sent the beacon
+ val isEmitter = currentUserId != null && currentUserId == locationUserId
+ val messageLayout = attributes.informationData.messageLayout
+ val viewState = buildViewState(holder, messageLayout, isEmitter)
+ holder.locationLiveMessageBanner.isVisible = true
+ holder.locationLiveMessageBanner.render(viewState)
+ holder.locationLiveMessageBanner.stopButton.setOnClickListener {
+ attributes.callback?.onTimelineItemAction(RoomDetailAction.StopLiveLocationSharing)
+ }
+ }
+
+ private fun buildViewState(
+ holder: Holder,
+ messageLayout: TimelineMessageLayout,
+ isEmitter: Boolean
+ ): LocationLiveMessageBannerViewState {
+ return when {
+ messageLayout is TimelineMessageLayout.Bubble && isEmitter ->
+ LocationLiveMessageBannerViewState.Emitter(
+ remainingTimeInMillis = getRemainingTimeOfLiveInMillis(),
+ bottomStartCornerRadiusInDp = messageLayout.cornersRadius.bottomStartRadius,
+ bottomEndCornerRadiusInDp = messageLayout.cornersRadius.bottomEndRadius,
+ isStopButtonCenteredVertically = false
+ )
+ messageLayout is TimelineMessageLayout.Bubble ->
+ LocationLiveMessageBannerViewState.Watcher(
+ bottomStartCornerRadiusInDp = messageLayout.cornersRadius.bottomStartRadius,
+ bottomEndCornerRadiusInDp = messageLayout.cornersRadius.bottomEndRadius,
+ formattedLocalTimeOfEndOfLive = getFormattedLocalTimeEndOfLive(),
+ )
+ isEmitter -> {
+ val cornerRadius = getBannerCornerRadiusForDefaultLayout(holder)
+ LocationLiveMessageBannerViewState.Emitter(
+ remainingTimeInMillis = getRemainingTimeOfLiveInMillis(),
+ bottomStartCornerRadiusInDp = cornerRadius,
+ bottomEndCornerRadiusInDp = cornerRadius,
+ isStopButtonCenteredVertically = true
+ )
+ }
+ else -> {
+ val cornerRadius = getBannerCornerRadiusForDefaultLayout(holder)
+ LocationLiveMessageBannerViewState.Watcher(
+ bottomStartCornerRadiusInDp = cornerRadius,
+ bottomEndCornerRadiusInDp = cornerRadius,
+ formattedLocalTimeOfEndOfLive = getFormattedLocalTimeEndOfLive(),
+ )
+ }
+ }
+ }
+
+ private fun getBannerCornerRadiusForDefaultLayout(holder: Holder): Float {
+ val dimensionConverter = DimensionConverter(holder.view.resources)
+ return dimensionConverter.dpToPx(8).toFloat()
+ }
+
+ private fun getFormattedLocalTimeEndOfLive() =
+ endOfLiveDateTime?.toTimestamp()?.let { vectorDateFormatter.format(it, DateFormatKind.MESSAGE_SIMPLE) }.orEmpty()
+
+ private fun getRemainingTimeOfLiveInMillis() =
+ (endOfLiveDateTime?.toTimestamp() ?: 0) - LocalDateTime.now().toTimestamp()
+
+ override fun getViewStubId() = STUB_ID
+
+ class Holder : AbsMessageLocationItem.Holder(STUB_ID) {
+ val locationLiveMessageBanner by bind(R.id.locationLiveMessageBanner)
+ }
+
+ companion object {
+ private const val STUB_ID = R.id.messageContentLiveLocationStub
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt
index 390db0ef50..001774b579 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt
@@ -16,22 +16,15 @@
package im.vector.app.features.home.room.detail.timeline.item
-import android.graphics.drawable.ColorDrawable
import android.widget.ImageView
-import androidx.core.view.updateLayoutParams
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
-import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners
-import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import im.vector.app.R
-import im.vector.app.core.glide.GlideApp
-import im.vector.app.core.utils.DimensionConverter
-import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
-import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
-import im.vector.app.features.themes.ThemeUtils
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
-abstract class MessageLiveLocationStartItem : AbsMessageItem() {
+abstract class MessageLiveLocationStartItem :
+ AbsMessageItem(),
+ LiveLocationShareStatusItem by DefaultLiveLocationShareStatusItem() {
@EpoxyAttribute
var mapWidth: Int = 0
@@ -42,44 +35,8 @@ abstract class MessageLiveLocationStartItem : AbsMessageItem() {
-
- @EpoxyAttribute
- var locationUrl: String? = null
-
- @EpoxyAttribute
- var userId: String? = null
-
- @EpoxyAttribute
- var mapWidth: Int = 0
-
- @EpoxyAttribute
- var mapHeight: Int = 0
-
- @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
- var locationPinProvider: LocationPinProvider? = null
-
- override fun bind(holder: Holder) {
- super.bind(holder)
- renderSendState(holder.view, null)
- val location = locationUrl ?: return
- val messageLayout = attributes.informationData.messageLayout
- val dimensionConverter = DimensionConverter(holder.view.resources)
- val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
- messageLayout.cornersRadius.granularRoundedCorners()
- } else {
- RoundedCorners(dimensionConverter.dpToPx(8))
- }
- holder.staticMapImageView.updateLayoutParams {
- width = mapWidth
- height = mapHeight
- }
- GlideApp.with(holder.staticMapImageView)
- .load(location)
- .apply(RequestOptions.centerCropTransform())
- .listener(object : RequestListener {
- override fun onLoadFailed(e: GlideException?,
- model: Any?,
- target: Target?,
- isFirstResource: Boolean): Boolean {
- holder.staticMapPinImageView.setImageResource(R.drawable.ic_location_pin_failed)
- holder.staticMapErrorTextView.isVisible = true
- return false
- }
-
- override fun onResourceReady(resource: Drawable?,
- model: Any?,
- target: Target?,
- dataSource: DataSource?,
- isFirstResource: Boolean): Boolean {
- locationPinProvider?.create(userId) { pinDrawable ->
- GlideApp.with(holder.staticMapPinImageView)
- .load(pinDrawable)
- .into(holder.staticMapPinImageView)
- }
- holder.staticMapErrorTextView.isVisible = false
- return false
- }
- })
- .transform(imageCornerTransformation)
- .into(holder.staticMapImageView)
- }
+abstract class MessageLocationItem : AbsMessageLocationItem() {
override fun getViewStubId() = STUB_ID
- class Holder : AbsMessageItem.Holder(STUB_ID) {
- val staticMapImageView by bind(R.id.staticMapImageView)
- val staticMapPinImageView by bind(R.id.staticMapPinImageView)
- val staticMapErrorTextView by bind(R.id.staticMapErrorTextView)
- }
+ class Holder : AbsMessageLocationItem.Holder(STUB_ID)
companion object {
private const val STUB_ID = R.id.messageContentLocationStub
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
index cb0b2384ec..a0d10a8a75 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
@@ -66,6 +66,11 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_BEACON_INFO,
)
+
+ private val MSG_TYPES_WITH_LOCATION_DATA = setOf(
+ MessageType.MSGTYPE_LOCATION,
+ MessageType.MSGTYPE_BEACON_LOCATION_DATA
+ )
}
private val cornerRadius: Float by lazy {
@@ -145,9 +150,11 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
}
private fun MessageContent?.timestampInsideMessage(): Boolean {
- if (this == null) return false
- if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline()
- return this.msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE
+ return when {
+ this == null -> false
+ msgType in MSG_TYPES_WITH_LOCATION_DATA -> vectorPreferences.labsRenderLocationsInTimeline()
+ else -> msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE
+ }
}
private fun MessageContent?.shouldAddMessageOverlay(): Boolean {
diff --git a/vector/src/main/java/im/vector/app/features/location/Config.kt b/vector/src/main/java/im/vector/app/features/location/Config.kt
index 6f947290e2..c29e2e911a 100644
--- a/vector/src/main/java/im/vector/app/features/location/Config.kt
+++ b/vector/src/main/java/im/vector/app/features/location/Config.kt
@@ -22,5 +22,5 @@ const val DEFAULT_PIN_ID = "DEFAULT_PIN_ID"
const val INITIAL_MAP_ZOOM_IN_PREVIEW = 15.0
const val INITIAL_MAP_ZOOM_IN_TIMELINE = 17.0
-const val MIN_TIME_TO_UPDATE_LOCATION_MILLIS = 5 * 1_000L // every 5 seconds
+const val MIN_TIME_TO_UPDATE_LOCATION_MILLIS = 2 * 1_000L // every 2 seconds
const val MIN_DISTANCE_TO_UPDATE_LOCATION_METERS = 10f
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationData.kt b/vector/src/main/java/im/vector/app/features/location/LocationData.kt
index 061f338e72..b3466ff871 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationData.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationData.kt
@@ -29,7 +29,7 @@ data class LocationData(
) : Parcelable
/**
- * Creates location data from a LocationContent.
+ * Creates location data from a MessageLocationContent.
* "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* @return location data or null if geo uri is not valid
*/
@@ -37,6 +37,15 @@ fun MessageLocationContent.toLocationData(): LocationData? {
return parseGeo(getBestGeoUri())
}
+/**
+ * Creates location data from a geoUri String.
+ * "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
+ * @return location data or null if geo uri is null or not valid
+ */
+fun String?.toLocationData(): LocationData? {
+ return this?.let { parseGeo(it) }
+}
+
@VisibleForTesting
fun parseGeo(geo: String): LocationData? {
val geoParts = geo
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt
index 362b82ccf5..8b9a1c75ae 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt
@@ -55,7 +55,10 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
private val binder = LocalBinder()
- private var roomArgsList = mutableListOf()
+ /**
+ * Keep track of a map between beacon event Id starting the live and RoomArgs.
+ */
+ private var roomArgsMap = mutableMapOf()
private var timers = mutableListOf()
override fun onCreate() {
@@ -73,8 +76,6 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
Timber.i("### LocationSharingService.onStartCommand. sessionId - roomId ${roomArgs?.sessionId} - ${roomArgs?.roomId}")
if (roomArgs != null) {
- roomArgsList.add(roomArgs)
-
// Show a sticky notification
val notification = notificationUtils.buildLiveLocationSharingNotification()
startForeground(roomArgs.roomId.hashCode(), notification)
@@ -87,7 +88,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
.getSafeActiveSession()
?.let { session ->
session.coroutineScope.launch(session.coroutineDispatchers.io) {
- sendLiveBeaconInfo(session, roomArgs)
+ sendStartingLiveBeaconInfo(session, roomArgs)
}
}
}
@@ -95,7 +96,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
return START_STICKY
}
- private suspend fun sendLiveBeaconInfo(session: Session, roomArgs: RoomArgs) {
+ private suspend fun sendStartingLiveBeaconInfo(session: Session, roomArgs: RoomArgs) {
val beaconContent = MessageBeaconInfoContent(
timeout = roomArgs.durationMillis,
isLive = true,
@@ -103,7 +104,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
).toContent()
val stateKey = session.myUserId
- session
+ val beaconEventId = session
.getRoom(roomArgs.roomId)
?.stateService()
?.sendStateEvent(
@@ -111,6 +112,16 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
stateKey = stateKey,
body = beaconContent
)
+
+ beaconEventId
+ ?.takeUnless { it.isEmpty() }
+ ?.let {
+ roomArgsMap[it] = roomArgs
+ locationTracker.requestLastKnownLocation()
+ }
+ ?: run {
+ Timber.w("### LocationSharingService.sendStartingLiveBeaconInfo error, no received beacon info id")
+ }
}
private fun scheduleTimer(roomId: String, durationMillis: Long) {
@@ -134,9 +145,13 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
// Send a new beacon info state by setting live field as false
sendStoppedBeaconInfo(roomId)
- synchronized(roomArgsList) {
- roomArgsList.removeAll { it.roomId == roomId }
- if (roomArgsList.isEmpty()) {
+ synchronized(roomArgsMap) {
+ val beaconIds = roomArgsMap
+ .filter { it.value.roomId == roomId }
+ .map { it.key }
+ beaconIds.forEach { roomArgsMap.remove(it) }
+
+ if (roomArgsMap.isEmpty()) {
Timber.i("### LocationSharingService. Destroying self, time is up for all rooms")
destroyMe()
}
@@ -156,16 +171,17 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
override fun onLocationUpdate(locationData: LocationData) {
Timber.i("### LocationSharingService.onLocationUpdate. Uncertainty: ${locationData.uncertainty}")
- val session = activeSessionHolder.getSafeActiveSession()
// Emit location update to all rooms in which live location sharing is active
- session?.coroutineScope?.launch(session.coroutineDispatchers.io) {
- roomArgsList.toList().forEach { roomArg ->
- sendLiveLocation(roomArg.roomId, locationData)
- }
+ roomArgsMap.toMap().forEach { item ->
+ sendLiveLocation(item.value.roomId, item.key, locationData)
}
}
- private suspend fun sendLiveLocation(roomId: String, locationData: LocationData) {
+ private fun sendLiveLocation(
+ roomId: String,
+ beaconInfoEventId: String,
+ locationData: LocationData
+ ) {
val session = activeSessionHolder.getSafeActiveSession()
val room = session?.getRoom(roomId)
val userId = session?.myUserId
@@ -174,18 +190,12 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
return
}
- room
- .stateService()
- .getLiveLocationBeaconInfo(userId, true)
- ?.eventId
- ?.let {
- room.sendService().sendLiveLocation(
- beaconInfoEventId = it,
- latitude = locationData.latitude,
- longitude = locationData.longitude,
- uncertainty = locationData.uncertainty
- )
- }
+ room.sendService().sendLiveLocation(
+ beaconInfoEventId = beaconInfoEventId,
+ latitude = locationData.latitude,
+ longitude = locationData.longitude,
+ uncertainty = locationData.uncertainty
+ )
}
override fun onLocationProviderIsNotAvailable() {
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt
index b7006370a6..4e56e7954c 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt
@@ -40,10 +40,12 @@ class LocationTracker @Inject constructor(
fun onLocationProviderIsNotAvailable()
}
- private var callbacks = mutableListOf()
+ private val callbacks = mutableListOf()
private var hasGpsProviderLiveLocation = false
+ private var lastLocation: LocationData? = null
+
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
Timber.d("## LocationTracker. start()")
@@ -92,6 +94,14 @@ class LocationTracker @Inject constructor(
callbacks.clear()
}
+ /**
+ * Request the last known location. It will be given async through Callback.
+ * Please ensure adding a callback to receive the value.
+ */
+ fun requestLastKnownLocation() {
+ lastLocation?.let { location -> callbacks.forEach { it.onLocationUpdate(location) } }
+ }
+
fun addCallback(callback: Callback) {
if (!callbacks.contains(callback)) {
callbacks.add(callback)
@@ -127,7 +137,9 @@ class LocationTracker @Inject constructor(
}
}
}
- callbacks.forEach { it.onLocationUpdate(location.toLocationData()) }
+ val locationData = location.toLocationData()
+ lastLocation = locationData
+ callbacks.forEach { it.onLocationUpdate(locationData) }
}
override fun onProviderDisabled(provider: String) {
diff --git a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt
new file mode 100644
index 0000000000..8cb552e3c4
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.location.live
+
+import android.content.Context
+import android.graphics.drawable.ColorDrawable
+import android.os.CountDownTimer
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.view.isVisible
+import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners
+import im.vector.app.R
+import im.vector.app.core.glide.GlideApp
+import im.vector.app.core.utils.TextUtils
+import im.vector.app.databinding.ViewLocationLiveMessageBannerBinding
+import im.vector.app.features.themes.ThemeUtils
+import org.threeten.bp.Duration
+
+private const val REMAINING_TIME_COUNTER_INTERVAL_IN_MS = 1000L
+
+class LocationLiveMessageBannerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+
+ private val binding = ViewLocationLiveMessageBannerBinding.inflate(
+ LayoutInflater.from(context),
+ this
+ )
+
+ val stopButton: Button
+ get() = binding.locationLiveMessageBannerStop
+
+ private val background: ImageView
+ get() = binding.locationLiveMessageBannerBackground
+
+ private val title: TextView
+ get() = binding.locationLiveMessageBannerTitle
+
+ private val subTitle: TextView
+ get() = binding.locationLiveMessageBannerSubTitle
+
+ private var countDownTimer: CountDownTimer? = null
+
+ fun render(viewState: LocationLiveMessageBannerViewState) {
+ when (viewState) {
+ is LocationLiveMessageBannerViewState.Emitter -> renderEmitter(viewState)
+ is LocationLiveMessageBannerViewState.Watcher -> renderWatcher(viewState)
+ }
+
+ GlideApp.with(context)
+ .load(ColorDrawable(ThemeUtils.getColor(context, android.R.attr.colorBackground)))
+ .transform(GranularRoundedCorners(0f, 0f, viewState.bottomEndCornerRadiusInDp, viewState.bottomStartCornerRadiusInDp))
+ .into(background)
+ }
+
+ private fun renderEmitter(viewState: LocationLiveMessageBannerViewState.Emitter) {
+ stopButton.isVisible = true
+ title.text = context.getString(R.string.location_share_live_enabled)
+
+ countDownTimer?.cancel()
+ viewState.remainingTimeInMillis
+ .takeIf { it >= 0 }
+ ?.let {
+ countDownTimer = object : CountDownTimer(it, REMAINING_TIME_COUNTER_INTERVAL_IN_MS) {
+ override fun onTick(millisUntilFinished: Long) {
+ val duration = Duration.ofMillis(millisUntilFinished.coerceAtLeast(0L))
+ subTitle.text = context.getString(
+ R.string.location_share_live_remaining_time,
+ TextUtils.formatDurationWithUnits(context, duration)
+ )
+ }
+
+ override fun onFinish() {
+ subTitle.text = context.getString(
+ R.string.location_share_live_remaining_time,
+ TextUtils.formatDurationWithUnits(context, Duration.ofMillis(0L))
+ )
+ }
+ }
+ countDownTimer?.start()
+ }
+
+ val rootLayout: ConstraintLayout? = (binding.root as? ConstraintLayout)
+ rootLayout?.let { parentLayout ->
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(rootLayout)
+
+ if (viewState.isStopButtonCenteredVertically) {
+ constraintSet.connect(
+ R.id.locationLiveMessageBannerStop,
+ ConstraintSet.BOTTOM,
+ R.id.locationLiveMessageBannerBackground,
+ ConstraintSet.BOTTOM,
+ 0
+ )
+ } else {
+ constraintSet.clear(R.id.locationLiveMessageBannerStop, ConstraintSet.BOTTOM)
+ }
+
+ constraintSet.applyTo(parentLayout)
+ }
+ }
+
+ private fun renderWatcher(viewState: LocationLiveMessageBannerViewState.Watcher) {
+ stopButton.isVisible = false
+ title.text = context.getString(R.string.location_share_live_view)
+ subTitle.text = context.getString(R.string.location_share_live_until, viewState.formattedLocalTimeOfEndOfLive)
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerViewState.kt b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerViewState.kt
new file mode 100644
index 0000000000..976085386b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerViewState.kt
@@ -0,0 +1,36 @@
+/*
+ * 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
+
+sealed class LocationLiveMessageBannerViewState(
+ open val bottomStartCornerRadiusInDp: Float,
+ open val bottomEndCornerRadiusInDp: Float,
+) {
+
+ data class Emitter(
+ override val bottomStartCornerRadiusInDp: Float,
+ override val bottomEndCornerRadiusInDp: Float,
+ val remainingTimeInMillis: Long,
+ val isStopButtonCenteredVertically: Boolean
+ ) : LocationLiveMessageBannerViewState(bottomStartCornerRadiusInDp, bottomEndCornerRadiusInDp)
+
+ data class Watcher(
+ override val bottomStartCornerRadiusInDp: Float,
+ override val bottomEndCornerRadiusInDp: Float,
+ val formattedLocalTimeOfEndOfLive: String,
+ ) : LocationLiveMessageBannerViewState(bottomStartCornerRadiusInDp, bottomEndCornerRadiusInDp)
+}
diff --git a/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp
index 23a45700f0..3241b5dc82 100644
Binary files a/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp
index a6130fba78..03f9ba5062 100644
Binary files a/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-night-hdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-hdpi/bg_no_location_map.webp
new file mode 100644
index 0000000000..76e0a75dd6
Binary files /dev/null and b/vector/src/main/res/drawable-night-hdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-night-mdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-mdpi/bg_no_location_map.webp
new file mode 100644
index 0000000000..79900cec1b
Binary files /dev/null and b/vector/src/main/res/drawable-night-mdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-night-xhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-xhdpi/bg_no_location_map.webp
new file mode 100644
index 0000000000..14f7e0e44c
Binary files /dev/null and b/vector/src/main/res/drawable-night-xhdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-night-xxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-xxhdpi/bg_no_location_map.webp
new file mode 100644
index 0000000000..91cb7c8eb6
Binary files /dev/null and b/vector/src/main/res/drawable-night-xxhdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-night-xxxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-xxxhdpi/bg_no_location_map.webp
new file mode 100644
index 0000000000..e4864a9eb2
Binary files /dev/null and b/vector/src/main/res/drawable-night-xxxhdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp
index e908191371..513089b55b 100644
Binary files a/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp
index e062178367..50284965a7 100644
Binary files a/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp
index 8b110d33fe..881af0055a 100644
Binary files a/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp differ
diff --git a/vector/src/main/res/layout/item_timeline_event_live_location_inactive_stub.xml b/vector/src/main/res/layout/item_timeline_event_live_location_inactive_stub.xml
new file mode 100644
index 0000000000..d5a0cefb28
--- /dev/null
+++ b/vector/src/main/res/layout/item_timeline_event_live_location_inactive_stub.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vector/src/main/res/layout/item_timeline_event_live_location_start_stub.xml b/vector/src/main/res/layout/item_timeline_event_live_location_start_stub.xml
index b81a6cc0e9..1726928721 100644
--- a/vector/src/main/res/layout/item_timeline_event_live_location_start_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_live_location_start_stub.xml
@@ -19,8 +19,8 @@
android:id="@+id/locationLiveStartBanner"
android:layout_width="0dp"
android:layout_height="48dp"
- android:alpha="0.85"
- android:src="?colorSurface"
+ android:alpha="0.75"
+ android:src="?android:colorBackground"
app:layout_constraintBottom_toBottomOf="@id/locationLiveStartMap"
app:layout_constraintEnd_toEndOf="@id/locationLiveStartMap"
app:layout_constraintStart_toStartOf="@id/locationLiveStartMap"
@@ -28,9 +28,10 @@
+ app:layout_constraintTop_toTopOf="@id/staticMapImageView"
+ app:layout_constraintVertical_bias="1.0" />
+
+
+
+
diff --git a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml
index 355d5fa7fe..0d45a48b9b 100644
--- a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml
+++ b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml
@@ -59,12 +59,24 @@
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_location_stub" />
+
+
+
+
diff --git a/vector/src/main/res/layout/view_location_live_message_banner.xml b/vector/src/main/res/layout/view_location_live_message_banner.xml
new file mode 100644
index 0000000000..35924541d1
--- /dev/null
+++ b/vector/src/main/res/layout/view_location_live_message_banner.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml
index 22c35f6324..20bedcaa1e 100644
--- a/vector/src/main/res/values/strings.xml
+++ b/vector/src/main/res/values/strings.xml
@@ -322,6 +322,13 @@
Start Chatting
Spaces
+
+ h
+
+ min
+
+ sec
+
Some permissions are missing to perform this action, please grant the permissions from the system settings.
To perform this action, please grant the Camera permission from the system settings.
@@ -3006,7 +3013,13 @@
Failed to load map
Live location enabled
Loading live location…
+ Live location ended
+ View live location
+
+ Live until %1$s
Stop
+
+ %1$s left
${app_name} Live Location
Location sharing is in progress