Merge pull request #5989 from vector-im/feature/mna/PSF-884-location-view

[Location sharing] - Message for live sharing in timeline (PSF-884)
This commit is contained in:
Maxime NATUREL 2022-05-18 15:39:51 +02:00 committed by GitHub
commit 738ce18a2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1202 additions and 386 deletions

1
changelog.d/5689.wip Normal file
View File

@ -0,0 +1 @@
[Live location sharing] Update message in timeline during the live

View File

@ -2,10 +2,20 @@
<resources> <resources>
<style name="Widget.Vector.Button.Text.OnPrimary.LocationLive"> <style name="Widget.Vector.Button.Text.OnPrimary.LocationLive">
<item name="android:background">?selectableItemBackground</item> <item name="android:foreground">?selectableItemBackground</item>
<item name="android:background">@android:color/transparent</item>
<item name="android:textSize">12sp</item> <item name="android:textSize">12sp</item>
<item name="android:padding">0dp</item> <item name="android:padding">0dp</item>
<item name="android:gravity">center</item> <item name="android:gravity">center</item>
</style> </style>
<style name="Widget.Vector.Button.Text.LocationLive">
<item name="android:foreground">?selectableItemBackground</item>
<item name="android:background">@android:color/transparent</item>
<item name="android:textAppearance">@style/TextAppearance.Vector.Body.Medium</item>
<item name="android:textColor">?colorError</item>
<item name="android:padding">0dp</item>
<item name="android:gravity">center</item>
</style>
</resources> </resources>

View File

@ -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.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.room.model.Membership 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.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.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
@ -375,11 +376,11 @@ fun Event.getRelationContent(): RelationDefaultContent? {
content.toModel<EncryptedEventContent>()?.relatesTo content.toModel<EncryptedEventContent>()?.relatesTo
} else { } else {
content.toModel<MessageContent>()?.relatesTo ?: run { content.toModel<MessageContent>()?.relatesTo ?: run {
// Special case to handle stickers, while there is only a local msgtype for stickers // Special cases when there is only a local msgtype for some event types
if (getClearType() == EventType.STICKER) { when (getClearType()) {
getClearContent().toModel<MessageStickerContent>()?.relatesTo EventType.STICKER -> getClearContent().toModel<MessageStickerContent>()?.relatesTo
} else { in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel<MessageBeaconLocationDataContent>()?.relatesTo
null else -> null
} }
} }
} }

View File

@ -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.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReadReceipt 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.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.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
@ -140,6 +141,7 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>() EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>() in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageBeaconInfoContent>() in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageBeaconInfoContent>()
in EventType.BEACON_LOCATION_DATA -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
} }
} }

View File

@ -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.openid.DefaultOpenIdService
import org.matrix.android.sdk.internal.session.permalinks.DefaultPermalinkService 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.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.create.RoomCreateEventProcessor
import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcessor import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcessor
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
@ -387,7 +385,4 @@ internal abstract class SessionModule {
@Binds @Binds
abstract fun bindEventSenderProcessor(processor: EventSenderProcessorCoroutine): EventSenderProcessor abstract fun bindEventSenderProcessor(processor: EventSenderProcessorCoroutine): EventSenderProcessor
@Binds
abstract fun bindLiveLocationAggregationProcessor(processor: DefaultLiveLocationAggregationProcessor): LiveLocationAggregationProcessor
} }

View File

@ -193,9 +193,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
} }
} }
in EventType.BEACON_LOCATION_DATA -> { in EventType.BEACON_LOCATION_DATA -> {
event.getClearContent().toModel<MessageBeaconLocationDataContent>(catchError = true)?.let { handleBeaconLocationData(event, realm, roomId, isLocalEcho)
liveLocationAggregationProcessor.handleBeaconLocationData(realm, event, it, roomId, isLocalEcho)
}
} }
} }
} else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) { } else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) {
@ -260,6 +258,9 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
liveLocationAggregationProcessor.handleBeaconInfo(realm, event, it, roomId, isLocalEcho) liveLocationAggregationProcessor.handleBeaconInfo(realm, event, it, roomId, isLocalEcho)
} }
} }
in EventType.BEACON_LOCATION_DATA -> {
handleBeaconLocationData(event, realm, roomId, isLocalEcho)
}
else -> Timber.v("UnHandled event ${event.eventId}") else -> Timber.v("UnHandled event ${event.eventId}")
} }
} catch (t: Throwable) { } catch (t: Throwable) {
@ -756,4 +757,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
verifSummary.sourceEvents.add(event.eventId) verifSummary.sourceEvents.add(event.eventId)
} }
} }
private fun handleBeaconLocationData(event: Event, realm: Realm, roomId: String, isLocalEcho: Boolean) {
event.getClearContent().toModel<MessageBeaconLocationDataContent>(catchError = true)?.let {
liveLocationAggregationProcessor.handleBeaconLocationData(
realm,
event,
it,
roomId,
event.getRelationContent()?.eventId,
isLocalEcho
)
}
}
} }

View File

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

View File

@ -17,24 +17,83 @@
package org.matrix.android.sdk.internal.session.room.aggregation.livelocation package org.matrix.android.sdk.internal.session.room.aggregation.livelocation
import io.realm.Realm 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.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.MessageBeaconInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent 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 { internal class LiveLocationAggregationProcessor @Inject constructor() {
fun handleBeaconInfo(
realm: Realm, fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) {
event: Event, if (event.senderId.isNullOrEmpty() || isLocalEcho) {
content: MessageBeaconInfoContent, return
roomId: String, }
isLocalEcho: Boolean,
) 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( fun handleBeaconLocationData(
realm: Realm, realm: Realm,
event: Event, event: Event,
content: MessageBeaconLocationDataContent, content: MessageBeaconLocationDataContent,
roomId: String, 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<MessageBeaconLocationDataContent>()
?.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
} }

View File

@ -19,27 +19,30 @@ package im.vector.app.core.resources
import org.threeten.bp.Instant import org.threeten.bp.Instant
import org.threeten.bp.LocalDateTime import org.threeten.bp.LocalDateTime
import org.threeten.bp.ZoneId import org.threeten.bp.ZoneId
import org.threeten.bp.ZoneOffset
object DateProvider { object DateProvider {
private val zoneId = ZoneId.systemDefault() // recompute the zoneId each time we access it to handle change of timezones
private val zoneOffset by lazy { private val defaultZoneId: ZoneId
val now = currentLocalDateTime() get() = ZoneId.systemDefault()
zoneId.rules.getOffset(now)
} // 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 { fun toLocalDateTime(timestamp: Long?): LocalDateTime {
val instant = Instant.ofEpochMilli(timestamp ?: 0) val instant = Instant.ofEpochMilli(timestamp ?: 0)
return LocalDateTime.ofInstant(instant, zoneId) return LocalDateTime.ofInstant(instant, defaultZoneId)
} }
fun currentLocalDateTime(): LocalDateTime { fun currentLocalDateTime(): LocalDateTime {
val instant = Instant.now() val instant = Instant.now()
return LocalDateTime.ofInstant(instant, zoneId) return LocalDateTime.ofInstant(instant, defaultZoneId)
} }
fun toTimestamp(localDateTime: LocalDateTime): Long { fun toTimestamp(localDateTime: LocalDateTime): Long {
return localDateTime.toInstant(zoneOffset).toEpochMilli() return localDateTime.toInstant(defaultZoneOffset).toEpochMilli()
} }
} }

View File

@ -19,11 +19,15 @@ package im.vector.app.core.utils
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.text.format.Formatter import android.text.format.Formatter
import im.vector.app.R
import org.threeten.bp.Duration import org.threeten.bp.Duration
import java.util.TreeMap import java.util.TreeMap
object TextUtils { object TextUtils {
private const val MINUTES_PER_HOUR = 60
private const val SECONDS_PER_MINUTE = 60
private val suffixes = TreeMap<Int, String>().also { private val suffixes = TreeMap<Int, String>().also {
it[1000] = "k" it[1000] = "k"
it[1000000] = "M" it[1000000] = "M"
@ -71,13 +75,63 @@ object TextUtils {
} }
fun formatDuration(duration: Duration): String { fun formatDuration(duration: Duration): String {
val hours = duration.seconds / 3600 val hours = getHours(duration)
val minutes = (duration.seconds % 3600) / 60 val minutes = getMinutes(duration)
val seconds = duration.seconds % 60 val seconds = getSeconds(duration)
return if (hours > 0) { return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds) String.format("%d:%02d:%02d", hours, minutes, seconds)
} else { } else {
String.format("%02d:%02d", minutes, seconds) 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()
} }

View File

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

View File

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

View File

@ -148,7 +148,7 @@ class MessageItemFactory @Inject constructor(
private val locationPinProvider: LocationPinProvider, private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider, private val urlMapProvider: UrlMapProvider,
private val liveLocationMessageItemFactory: LiveLocationMessageItemFactory, private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory,
) { ) {
// TODO inject this properly? // TODO inject this properly?
@ -216,7 +216,7 @@ class MessageItemFactory @Inject constructor(
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) 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) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
} }
return messageItem?.apply { return messageItem?.apply {
@ -237,14 +237,14 @@ class MessageItemFactory @Inject constructor(
urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height) 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_() return MessageLocationItem_()
.attributes(attributes) .attributes(attributes)
.locationUrl(locationUrl) .locationUrl(locationUrl)
.mapWidth(width) .mapWidth(width)
.mapHeight(height) .mapHeight(height)
.userId(userId) .locationUserId(locationUserId)
.locationPinProvider(locationPinProvider) .locationPinProvider(locationPinProvider)
.highlighted(highlight) .highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)

View File

@ -100,7 +100,7 @@ class TimelineItemFactory @Inject constructor(
// Message itemsX // Message itemsX
EventType.STICKER, EventType.STICKER,
in EventType.POLL_START, in EventType.POLL_START,
EventType.MESSAGE -> messageItemFactory.create(params) EventType.MESSAGE -> messageItemFactory.create(params)
EventType.REDACTION, EventType.REDACTION,
EventType.KEY_VERIFICATION_ACCEPT, EventType.KEY_VERIFICATION_ACCEPT,
EventType.KEY_VERIFICATION_START, EventType.KEY_VERIFICATION_START,
@ -113,14 +113,15 @@ class TimelineItemFactory @Inject constructor(
EventType.CALL_NEGOTIATE, EventType.CALL_NEGOTIATE,
EventType.REACTION, EventType.REACTION,
in EventType.POLL_RESPONSE, in EventType.POLL_RESPONSE,
in EventType.POLL_END -> noticeItemFactory.create(params) in EventType.POLL_END,
in EventType.BEACON_LOCATION_DATA -> noticeItemFactory.create(params)
// Calls // Calls
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_REJECT, EventType.CALL_REJECT,
EventType.CALL_ANSWER -> callItemFactory.create(params) EventType.CALL_ANSWER -> callItemFactory.create(params)
// Crypto // Crypto
EventType.ENCRYPTED -> { EventType.ENCRYPTED -> {
if (event.root.isRedacted()) { if (event.root.isRedacted()) {
// Redacted event, let the MessageItemFactory handle it // Redacted event, let the MessageItemFactory handle it
messageItemFactory.create(params) messageItemFactory.create(params)
@ -129,11 +130,11 @@ class TimelineItemFactory @Inject constructor(
} }
} }
EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_CANCEL,
EventType.KEY_VERIFICATION_DONE -> { EventType.KEY_VERIFICATION_DONE -> {
verificationConclusionItemFactory.create(params) verificationConclusionItemFactory.create(params)
} }
// Unhandled event types // Unhandled event types
else -> { else -> {
// Should only happen when shouldShowHiddenEvents() settings is ON // Should only happen when shouldShowHiddenEvents() settings is ON
Timber.v("Type ${event.root.getClearType()} not handled") Timber.v("Type ${event.root.getClearType()} not handled")
defaultItemFactory.create(params) defaultItemFactory.create(params)

View File

@ -107,7 +107,8 @@ class NoticeEventFormatter @Inject constructor(
EventType.REDACTION, EventType.REDACTION,
EventType.STICKER, EventType.STICKER,
in EventType.POLL_RESPONSE, in EventType.POLL_RESPONSE,
in EventType.POLL_END -> formatDebug(timelineEvent.root) in EventType.POLL_END,
in EventType.BEACON_LOCATION_DATA -> formatDebug(timelineEvent.root)
else -> { else -> {
Timber.v("Type $type not handled by this formatter") Timber.v("Type $type not handled by this formatter")
null null

View File

@ -44,8 +44,7 @@ import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited
import javax.inject.Inject 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. * This class is responsible of building extra information data associated to a given event.
* TODO Update this comment
*/ */
class MessageInformationDataFactory @Inject constructor(private val session: Session, class MessageInformationDataFactory @Inject constructor(private val session: Session,
private val dateFormatter: VectorDateFormatter, private val dateFormatter: VectorDateFormatter,
@ -119,7 +118,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
isFirstFromThisSender = isFirstFromThisSender, isFirstFromThisSender = isFirstFromThisSender,
isLastFromThisSender = isLastFromThisSender, isLastFromThisSender = isLastFromThisSender,
e2eDecoration = e2eDecoration, e2eDecoration = e2eDecoration,
sendStateDecoration = sendStateDecoration sendStateDecoration = sendStateDecoration,
) )
} }

View File

@ -57,6 +57,7 @@ class MessageItemAttributesFactory @Inject constructor(
memberClickListener = { memberClickListener = {
callback?.onMemberNameClicked(informationData) callback?.onMemberNameClicked(informationData)
}, },
callback = callback,
reactionPillCallback = callback, reactionPillCallback = callback,
avatarCallback = callback, avatarCallback = callback,
threadCallback = callback, threadCallback = callback,

View File

@ -178,6 +178,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
override val itemLongClickListener: View.OnLongClickListener? = null, override val itemLongClickListener: View.OnLongClickListener? = null,
override val itemClickListener: ClickListener? = null, override val itemClickListener: ClickListener? = null,
val memberClickListener: ClickListener? = null, val memberClickListener: ClickListener? = null,
val callback: TimelineEventController.Callback? = null,
override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null,
val avatarCallback: TimelineEventController.AvatarCallback? = null, val avatarCallback: TimelineEventController.AvatarCallback? = null,
val threadCallback: TimelineEventController.ThreadCallback? = null, val threadCallback: TimelineEventController.ThreadCallback? = null,

View File

@ -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<H : AbsMessageLocationItem.Holder> : AbsMessageItem<H>() {
@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<Drawable> {
override fun onLoadFailed(e: GlideException?,
model: Any?,
target: Target<Drawable>?,
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<Drawable>?,
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<ImageView>(R.id.staticMapImageView)
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
val staticMapErrorTextView by bind<TextView>(R.id.staticMapErrorTextView)
}
}

View File

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

View File

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

View File

@ -42,7 +42,7 @@ data class MessageInformationData(
val e2eDecoration: E2EDecoration = E2EDecoration.NONE, val e2eDecoration: E2EDecoration = E2EDecoration.NONE,
val sendStateDecoration: SendStateDecoration = SendStateDecoration.NONE, val sendStateDecoration: SendStateDecoration = SendStateDecoration.NONE,
val isFirstFromThisSender: Boolean = false, val isFirstFromThisSender: Boolean = false,
val isLastFromThisSender: Boolean = false val isLastFromThisSender: Boolean = false,
) : Parcelable { ) : Parcelable {
val matrixItem: MatrixItem val matrixItem: MatrixItem

View File

@ -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<MessageLiveLocationInactiveItem.Holder>(),
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<ImageView>(R.id.locationLiveInactiveBanner)
val noLocationMapImageView by bind<ImageView>(R.id.locationLiveInactiveMap)
}
companion object {
private const val STUB_ID = R.id.messageContentLiveLocationInactiveStub
}
}

View File

@ -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<MessageLiveLocationItem.Holder>() {
@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<LocationLiveMessageBannerView>(R.id.locationLiveMessageBanner)
}
companion object {
private const val STUB_ID = R.id.messageContentLiveLocationStub
}
}

View File

@ -16,22 +16,15 @@
package im.vector.app.features.home.room.detail.timeline.item package im.vector.app.features.home.room.detail.timeline.item
import android.graphics.drawable.ColorDrawable
import android.widget.ImageView import android.widget.ImageView
import androidx.core.view.updateLayoutParams
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass 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.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) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLiveLocationStartItem : AbsMessageItem<MessageLiveLocationStartItem.Holder>() { abstract class MessageLiveLocationStartItem :
AbsMessageItem<MessageLiveLocationStartItem.Holder>(),
LiveLocationShareStatusItem by DefaultLiveLocationShareStatusItem() {
@EpoxyAttribute @EpoxyAttribute
var mapWidth: Int = 0 var mapWidth: Int = 0
@ -42,44 +35,8 @@ abstract class MessageLiveLocationStartItem : AbsMessageItem<MessageLiveLocation
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
renderSendState(holder.view, null) renderSendState(holder.view, null)
bindMap(holder) bindMap(holder.noLocationMapImageView, mapWidth, mapHeight, attributes.informationData.messageLayout)
bindBottomBanner(holder) bindBottomBanner(holder.bannerImageView, attributes.informationData.messageLayout)
}
private fun bindMap(holder: Holder) {
val messageLayout = attributes.informationData.messageLayout
val mapCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
messageLayout.cornersRadius.granularRoundedCorners()
} else {
RoundedCorners(getDefaultLayoutCornerRadiusInDp(holder))
}
holder.noLocationMapImageView.updateLayoutParams {
width = mapWidth
height = mapHeight
}
GlideApp.with(holder.noLocationMapImageView)
.load(R.drawable.bg_no_location_map)
.transform(mapCornerTransformation)
.into(holder.noLocationMapImageView)
}
private fun bindBottomBanner(holder: Holder) {
val messageLayout = attributes.informationData.messageLayout
val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
GranularRoundedCorners(0f, 0f, messageLayout.cornersRadius.bottomEndRadius, messageLayout.cornersRadius.bottomStartRadius)
} else {
val bottomCornerRadius = getDefaultLayoutCornerRadiusInDp(holder).toFloat()
GranularRoundedCorners(0f, 0f, bottomCornerRadius, bottomCornerRadius)
}
GlideApp.with(holder.bannerImageView)
.load(ColorDrawable(ThemeUtils.getColor(holder.bannerImageView.context, R.attr.colorSurface)))
.transform(imageCornerTransformation)
.into(holder.bannerImageView)
}
private fun getDefaultLayoutCornerRadiusInDp(holder: Holder): Int {
val dimensionConverter = DimensionConverter(holder.view.resources)
return dimensionConverter.dpToPx(8)
} }
override fun getViewStubId() = STUB_ID override fun getViewStubId() = STUB_ID

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2021 New Vector Ltd * Copyright (c) 2022 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -16,97 +16,15 @@
package im.vector.app.features.home.room.detail.timeline.item 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.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
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.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
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>() { abstract class MessageLocationItem : AbsMessageLocationItem<MessageLocationItem.Holder>() {
@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<Drawable> {
override fun onLoadFailed(e: GlideException?,
model: Any?,
target: Target<Drawable>?,
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<Drawable>?,
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)
}
override fun getViewStubId() = STUB_ID override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageLocationItem.Holder(STUB_ID)
val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
val staticMapErrorTextView by bind<TextView>(R.id.staticMapErrorTextView)
}
companion object { companion object {
private const val STUB_ID = R.id.messageContentLocationStub private const val STUB_ID = R.id.messageContentLocationStub

View File

@ -66,6 +66,11 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
MessageType.MSGTYPE_VIDEO, MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_BEACON_INFO, 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 { private val cornerRadius: Float by lazy {
@ -145,9 +150,11 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
} }
private fun MessageContent?.timestampInsideMessage(): Boolean { private fun MessageContent?.timestampInsideMessage(): Boolean {
if (this == null) return false return when {
if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline() this == null -> false
return this.msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE msgType in MSG_TYPES_WITH_LOCATION_DATA -> vectorPreferences.labsRenderLocationsInTimeline()
else -> msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE
}
} }
private fun MessageContent?.shouldAddMessageOverlay(): Boolean { private fun MessageContent?.shouldAddMessageOverlay(): Boolean {

View File

@ -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_PREVIEW = 15.0
const val INITIAL_MAP_ZOOM_IN_TIMELINE = 17.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 const val MIN_DISTANCE_TO_UPDATE_LOCATION_METERS = 10f

View File

@ -29,7 +29,7 @@ data class LocationData(
) : Parcelable ) : 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) * "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* @return location data or null if geo uri is not valid * @return location data or null if geo uri is not valid
*/ */
@ -37,6 +37,15 @@ fun MessageLocationContent.toLocationData(): LocationData? {
return parseGeo(getBestGeoUri()) 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 @VisibleForTesting
fun parseGeo(geo: String): LocationData? { fun parseGeo(geo: String): LocationData? {
val geoParts = geo val geoParts = geo

View File

@ -55,7 +55,10 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
private val binder = LocalBinder() private val binder = LocalBinder()
private var roomArgsList = mutableListOf<RoomArgs>() /**
* Keep track of a map between beacon event Id starting the live and RoomArgs.
*/
private var roomArgsMap = mutableMapOf<String, RoomArgs>()
private var timers = mutableListOf<Timer>() private var timers = mutableListOf<Timer>()
override fun onCreate() { override fun onCreate() {
@ -73,8 +76,6 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
Timber.i("### LocationSharingService.onStartCommand. sessionId - roomId ${roomArgs?.sessionId} - ${roomArgs?.roomId}") Timber.i("### LocationSharingService.onStartCommand. sessionId - roomId ${roomArgs?.sessionId} - ${roomArgs?.roomId}")
if (roomArgs != null) { if (roomArgs != null) {
roomArgsList.add(roomArgs)
// Show a sticky notification // Show a sticky notification
val notification = notificationUtils.buildLiveLocationSharingNotification() val notification = notificationUtils.buildLiveLocationSharingNotification()
startForeground(roomArgs.roomId.hashCode(), notification) startForeground(roomArgs.roomId.hashCode(), notification)
@ -87,7 +88,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
.getSafeActiveSession() .getSafeActiveSession()
?.let { session -> ?.let { session ->
session.coroutineScope.launch(session.coroutineDispatchers.io) { session.coroutineScope.launch(session.coroutineDispatchers.io) {
sendLiveBeaconInfo(session, roomArgs) sendStartingLiveBeaconInfo(session, roomArgs)
} }
} }
} }
@ -95,7 +96,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
return START_STICKY return START_STICKY
} }
private suspend fun sendLiveBeaconInfo(session: Session, roomArgs: RoomArgs) { private suspend fun sendStartingLiveBeaconInfo(session: Session, roomArgs: RoomArgs) {
val beaconContent = MessageBeaconInfoContent( val beaconContent = MessageBeaconInfoContent(
timeout = roomArgs.durationMillis, timeout = roomArgs.durationMillis,
isLive = true, isLive = true,
@ -103,7 +104,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
).toContent() ).toContent()
val stateKey = session.myUserId val stateKey = session.myUserId
session val beaconEventId = session
.getRoom(roomArgs.roomId) .getRoom(roomArgs.roomId)
?.stateService() ?.stateService()
?.sendStateEvent( ?.sendStateEvent(
@ -111,6 +112,16 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
stateKey = stateKey, stateKey = stateKey,
body = beaconContent 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) { 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 // Send a new beacon info state by setting live field as false
sendStoppedBeaconInfo(roomId) sendStoppedBeaconInfo(roomId)
synchronized(roomArgsList) { synchronized(roomArgsMap) {
roomArgsList.removeAll { it.roomId == roomId } val beaconIds = roomArgsMap
if (roomArgsList.isEmpty()) { .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") Timber.i("### LocationSharingService. Destroying self, time is up for all rooms")
destroyMe() destroyMe()
} }
@ -156,16 +171,17 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
override fun onLocationUpdate(locationData: LocationData) { override fun onLocationUpdate(locationData: LocationData) {
Timber.i("### LocationSharingService.onLocationUpdate. Uncertainty: ${locationData.uncertainty}") Timber.i("### LocationSharingService.onLocationUpdate. Uncertainty: ${locationData.uncertainty}")
val session = activeSessionHolder.getSafeActiveSession()
// Emit location update to all rooms in which live location sharing is active // Emit location update to all rooms in which live location sharing is active
session?.coroutineScope?.launch(session.coroutineDispatchers.io) { roomArgsMap.toMap().forEach { item ->
roomArgsList.toList().forEach { roomArg -> sendLiveLocation(item.value.roomId, item.key, locationData)
sendLiveLocation(roomArg.roomId, locationData)
}
} }
} }
private suspend fun sendLiveLocation(roomId: String, locationData: LocationData) { private fun sendLiveLocation(
roomId: String,
beaconInfoEventId: String,
locationData: LocationData
) {
val session = activeSessionHolder.getSafeActiveSession() val session = activeSessionHolder.getSafeActiveSession()
val room = session?.getRoom(roomId) val room = session?.getRoom(roomId)
val userId = session?.myUserId val userId = session?.myUserId
@ -174,18 +190,12 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
return return
} }
room room.sendService().sendLiveLocation(
.stateService() beaconInfoEventId = beaconInfoEventId,
.getLiveLocationBeaconInfo(userId, true) latitude = locationData.latitude,
?.eventId longitude = locationData.longitude,
?.let { uncertainty = locationData.uncertainty
room.sendService().sendLiveLocation( )
beaconInfoEventId = it,
latitude = locationData.latitude,
longitude = locationData.longitude,
uncertainty = locationData.uncertainty
)
}
} }
override fun onLocationProviderIsNotAvailable() { override fun onLocationProviderIsNotAvailable() {

View File

@ -40,10 +40,12 @@ class LocationTracker @Inject constructor(
fun onLocationProviderIsNotAvailable() fun onLocationProviderIsNotAvailable()
} }
private var callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
private var hasGpsProviderLiveLocation = false private var hasGpsProviderLiveLocation = false
private var lastLocation: LocationData? = null
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() { fun start() {
Timber.d("## LocationTracker. start()") Timber.d("## LocationTracker. start()")
@ -92,6 +94,14 @@ class LocationTracker @Inject constructor(
callbacks.clear() 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) { fun addCallback(callback: Callback) {
if (!callbacks.contains(callback)) { if (!callbacks.contains(callback)) {
callbacks.add(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) { override fun onProviderDisabled(provider: String) {

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- Size will be overrode -->
<ImageView
android:id="@+id/locationLiveInactiveMap"
android:layout_width="300dp"
android:layout_height="200dp"
android:contentDescription="@string/a11y_static_map_image"
android:src="@drawable/bg_no_location_map"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/locationLiveInactiveBanner"
android:layout_width="0dp"
android:layout_height="48dp"
android:alpha="0.75"
android:src="?android:colorBackground"
app:layout_constraintBottom_toBottomOf="@id/locationLiveInactiveMap"
app:layout_constraintEnd_toEndOf="@id/locationLiveInactiveMap"
app:layout_constraintStart_toStartOf="@id/locationLiveInactiveMap"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/locationLiveInactiveIcon"
android:layout_width="0dp"
android:layout_height="65dp"
android:src="@drawable/ic_attachment_location_white"
app:layout_constraintBottom_toTopOf="@id/locationLiveInactiveVerticalCenter"
app:layout_constraintEnd_toEndOf="@id/locationLiveInactiveMap"
app:layout_constraintStart_toStartOf="@id/locationLiveInactiveMap"
app:tint="?vctr_content_quaternary"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/locationLiveInactiveBannerIcon"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_marginVertical="8dp"
android:layout_marginStart="8dp"
android:background="@drawable/circle"
android:backgroundTint="?vctr_content_quaternary"
android:padding="3dp"
app:layout_constraintBottom_toBottomOf="@id/locationLiveInactiveBanner"
app:layout_constraintStart_toStartOf="@id/locationLiveInactiveBanner"
app:layout_constraintTop_toTopOf="@id/locationLiveInactiveBanner"
app:srcCompat="@drawable/ic_attachment_location_live_white"
app:tint="?android:colorBackground"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/locationLiveInactiveTitle"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:text="@string/location_share_live_ended"
android:textColor="?vctr_content_tertiary"
app:layout_constraintBottom_toBottomOf="@id/locationLiveInactiveBanner"
app:layout_constraintStart_toEndOf="@id/locationLiveInactiveBannerIcon"
app:layout_constraintTop_toTopOf="@id/locationLiveInactiveBanner" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/locationLiveInactiveVerticalCenter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.5" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -19,8 +19,8 @@
android:id="@+id/locationLiveStartBanner" android:id="@+id/locationLiveStartBanner"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="48dp"
android:alpha="0.85" android:alpha="0.75"
android:src="?colorSurface" android:src="?android:colorBackground"
app:layout_constraintBottom_toBottomOf="@id/locationLiveStartMap" app:layout_constraintBottom_toBottomOf="@id/locationLiveStartMap"
app:layout_constraintEnd_toEndOf="@id/locationLiveStartMap" app:layout_constraintEnd_toEndOf="@id/locationLiveStartMap"
app:layout_constraintStart_toStartOf="@id/locationLiveStartMap" app:layout_constraintStart_toStartOf="@id/locationLiveStartMap"
@ -28,9 +28,10 @@
<ImageView <ImageView
android:id="@+id/locationLiveStartIcon" android:id="@+id/locationLiveStartIcon"
android:layout_width="32dp" android:layout_width="26dp"
android:layout_height="32dp" android:layout_height="26dp"
android:layout_marginHorizontal="8dp" android:layout_marginVertical="8dp"
android:layout_marginStart="8dp"
android:background="@drawable/circle" android:background="@drawable/circle"
android:backgroundTint="?vctr_content_quaternary" android:backgroundTint="?vctr_content_quaternary"
android:padding="3dp" android:padding="3dp"
@ -38,6 +39,7 @@
app:layout_constraintStart_toStartOf="@id/locationLiveStartBanner" app:layout_constraintStart_toStartOf="@id/locationLiveStartBanner"
app:layout_constraintTop_toTopOf="@id/locationLiveStartBanner" app:layout_constraintTop_toTopOf="@id/locationLiveStartBanner"
app:srcCompat="@drawable/ic_attachment_location_live_white" app:srcCompat="@drawable/ic_attachment_location_live_white"
app:tint="?android:colorBackground"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
<TextView <TextView

View File

@ -21,13 +21,13 @@
android:layout_width="51dp" android:layout_width="51dp"
android:layout_height="55dp" android:layout_height="55dp"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginBottom="28dp"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:src="@drawable/bg_map_user_pin" android:src="@drawable/bg_map_user_pin"
app:layout_constraintBottom_toBottomOf="@id/staticMapImageView" app:layout_constraintBottom_toTopOf="@id/staticMapVerticalCenter"
app:layout_constraintEnd_toEndOf="@id/staticMapImageView" app:layout_constraintEnd_toEndOf="@id/staticMapImageView"
app:layout_constraintStart_toStartOf="@id/staticMapImageView" app:layout_constraintStart_toStartOf="@id/staticMapImageView"
app:layout_constraintTop_toTopOf="@id/staticMapImageView" /> app:layout_constraintTop_toTopOf="@id/staticMapImageView"
app:layout_constraintVertical_bias="1.0" />
<TextView <TextView
android:id="@+id/staticMapErrorTextView" android:id="@+id/staticMapErrorTextView"
@ -45,4 +45,21 @@
app:layout_constraintTop_toBottomOf="@id/staticMapPinImageView" app:layout_constraintTop_toBottomOf="@id/staticMapPinImageView"
tools:visibility="visible" /> tools:visibility="visible" />
<im.vector.app.features.location.live.LocationLiveMessageBannerView
android:id="@+id/locationLiveMessageBanner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/staticMapImageView"
app:layout_constraintEnd_toEndOf="@id/staticMapImageView"
app:layout_constraintStart_toStartOf="@id/staticMapImageView"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/staticMapVerticalCenter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.5" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -59,12 +59,24 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_location_stub" /> android:layout="@layout/item_timeline_event_location_stub" />
<ViewStub
android:id="@+id/messageContentLiveLocationStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_location_stub" />
<ViewStub <ViewStub
android:id="@+id/messageContentLiveLocationStartStub" android:id="@+id/messageContentLiveLocationStartStub"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_live_location_start_stub" /> android:layout="@layout/item_timeline_event_live_location_start_stub" />
<ViewStub
android:id="@+id/messageContentLiveLocationInactiveStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_live_location_inactive_stub" />
</FrameLayout> </FrameLayout>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<ImageView
android:id="@+id/locationLiveMessageBannerBackground"
android:layout_width="0dp"
android:layout_height="50dp"
android:alpha="0.75"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="?android:colorBackground"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/locationLiveMessageBannerIcon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginHorizontal="8dp"
android:background="@drawable/circle"
android:backgroundTint="?vctr_live_location"
android:padding="3dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/locationLiveMessageBannerBackground"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_attachment_location_live_white"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/locationLiveMessageBannerTitle"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
tools:text="@string/location_share_live_enabled"
android:textColor="?colorOnSurface"
app:layout_constraintBottom_toTopOf="@id/locationLiveMessageBannerSubTitle"
app:layout_constraintStart_toEndOf="@id/locationLiveMessageBannerIcon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/locationLiveMessageBannerSubTitle"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/locationLiveMessageBannerTitle"
app:layout_constraintTop_toBottomOf="@id/locationLiveMessageBannerTitle"
tools:text="9min left" />
<Button
android:id="@+id/locationLiveMessageBannerStop"
style="@style/Widget.Vector.Button.Text.LocationLive"
android:layout_width="45dp"
android:layout_height="30dp"
android:text="@string/location_share_live_stop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@id/locationLiveMessageBannerBackground"
app:layout_constraintTop_toTopOf="@id/locationLiveMessageBannerBackground" />
</merge>

View File

@ -322,6 +322,13 @@
<string name="start_chatting">Start Chatting</string> <string name="start_chatting">Start Chatting</string>
<string name="spaces">Spaces</string> <string name="spaces">Spaces</string>
<!-- Time unit for hour: if a short version exists, it should be used -->
<string name="time_unit_hour_short">h</string>
<!-- Time unit for minute: if a short version exists, it should be used -->
<string name="time_unit_minute_short">min</string>
<!-- Time unit for second: if a short version exists, it should be used -->
<string name="time_unit_second_short">sec</string>
<!-- Permissions denied forever --> <!-- Permissions denied forever -->
<string name="denied_permission_generic">Some permissions are missing to perform this action, please grant the permissions from the system settings.</string> <string name="denied_permission_generic">Some permissions are missing to perform this action, please grant the permissions from the system settings.</string>
<string name="denied_permission_camera">To perform this action, please grant the Camera permission from the system settings.</string> <string name="denied_permission_camera">To perform this action, please grant the Camera permission from the system settings.</string>
@ -3006,7 +3013,13 @@
<string name="location_timeline_failed_to_load_map">Failed to load map</string> <string name="location_timeline_failed_to_load_map">Failed to load map</string>
<string name="location_share_live_enabled">Live location enabled</string> <string name="location_share_live_enabled">Live location enabled</string>
<string name="location_share_live_started">Loading live location…</string> <string name="location_share_live_started">Loading live location…</string>
<string name="location_share_live_ended">Live location ended</string>
<string name="location_share_live_view">View live location</string>
<!-- Examples of usage: Live until 5:42 PM/Live until 17:42-->
<string name="location_share_live_until">Live until %1$s</string>
<string name="location_share_live_stop">Stop</string> <string name="location_share_live_stop">Stop</string>
<!-- Examples of usage: 6h 15min 30sec left/15min 30sec left/30 sec left-->
<string name="location_share_live_remaining_time">%1$s left</string>
<string name="live_location_sharing_notification_title">${app_name} Live Location</string> <string name="live_location_sharing_notification_title">${app_name} Live Location</string>
<string name="live_location_sharing_notification_description">Location sharing is in progress</string> <string name="live_location_sharing_notification_description">Location sharing is in progress</string>