Merge pull request #5989 from vector-im/feature/mna/PSF-884-location-view
[Location sharing] - Message for live sharing in timeline (PSF-884)
1
changelog.d/5689.wip
Normal file
@ -0,0 +1 @@
|
||||
[Live location sharing] Update message in timeline during the live
|
@ -2,10 +2,20 @@
|
||||
<resources>
|
||||
|
||||
<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:padding">0dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
</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>
|
||||
|
@ -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<EncryptedEventContent>()?.relatesTo
|
||||
} else {
|
||||
content.toModel<MessageContent>()?.relatesTo ?: run {
|
||||
// Special case to handle stickers, while there is only a local msgtype for stickers
|
||||
if (getClearType() == EventType.STICKER) {
|
||||
getClearContent().toModel<MessageStickerContent>()?.relatesTo
|
||||
} else {
|
||||
null
|
||||
// Special cases when there is only a local msgtype for some event types
|
||||
when (getClearType()) {
|
||||
EventType.STICKER -> getClearContent().toModel<MessageStickerContent>()?.relatesTo
|
||||
in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel<MessageBeaconLocationDataContent>()?.relatesTo
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<MessageStickerContent>()
|
||||
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.BEACON_LOCATION_DATA -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>()
|
||||
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -193,9 +193,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
|
||||
}
|
||||
}
|
||||
in EventType.BEACON_LOCATION_DATA -> {
|
||||
event.getClearContent().toModel<MessageBeaconLocationDataContent>(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<MessageBeaconLocationDataContent>(catchError = true)?.let {
|
||||
liveLocationAggregationProcessor.handleBeaconLocationData(
|
||||
realm,
|
||||
event,
|
||||
it,
|
||||
roomId,
|
||||
event.getRelationContent()?.eventId,
|
||||
isLocalEcho
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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<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
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<Int, String>().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()
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -113,7 +113,8 @@ 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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -57,6 +57,7 @@ class MessageItemAttributesFactory @Inject constructor(
|
||||
memberClickListener = {
|
||||
callback?.onMemberNameClicked(informationData)
|
||||
},
|
||||
callback = callback,
|
||||
reactionPillCallback = callback,
|
||||
avatarCallback = callback,
|
||||
threadCallback = callback,
|
||||
|
@ -178,6 +178,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
|
||||
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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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<MessageLiveLocationStartItem.Holder>() {
|
||||
abstract class MessageLiveLocationStartItem :
|
||||
AbsMessageItem<MessageLiveLocationStartItem.Holder>(),
|
||||
LiveLocationShareStatusItem by DefaultLiveLocationShareStatusItem() {
|
||||
|
||||
@EpoxyAttribute
|
||||
var mapWidth: Int = 0
|
||||
@ -42,44 +35,8 @@ abstract class MessageLiveLocationStartItem : AbsMessageItem<MessageLiveLocation
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
renderSendState(holder.view, null)
|
||||
bindMap(holder)
|
||||
bindBottomBanner(holder)
|
||||
}
|
||||
|
||||
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)
|
||||
bindMap(holder.noLocationMapImageView, mapWidth, mapHeight, attributes.informationData.messageLayout)
|
||||
bindBottomBanner(holder.bannerImageView, attributes.informationData.messageLayout)
|
||||
}
|
||||
|
||||
override fun getViewStubId() = STUB_ID
|
||||
|
@ -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");
|
||||
* 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
|
||||
|
||||
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.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
|
||||
|
||||
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
|
||||
abstract class MessageLocationItem : AbsMessageItem<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)
|
||||
}
|
||||
abstract class MessageLocationItem : AbsMessageLocationItem<MessageLocationItem.Holder>() {
|
||||
|
||||
override fun getViewStubId() = STUB_ID
|
||||
|
||||
class Holder : AbsMessageItem.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)
|
||||
}
|
||||
class Holder : AbsMessageLocationItem.Holder(STUB_ID)
|
||||
|
||||
companion object {
|
||||
private const val STUB_ID = R.id.messageContentLocationStub
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -55,7 +55,10 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
|
||||
|
||||
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>()
|
||||
|
||||
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,19 +190,13 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
|
||||
return
|
||||
}
|
||||
|
||||
room
|
||||
.stateService()
|
||||
.getLiveLocationBeaconInfo(userId, true)
|
||||
?.eventId
|
||||
?.let {
|
||||
room.sendService().sendLiveLocation(
|
||||
beaconInfoEventId = it,
|
||||
beaconInfoEventId = beaconInfoEventId,
|
||||
latitude = locationData.latitude,
|
||||
longitude = locationData.longitude,
|
||||
uncertainty = locationData.uncertainty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLocationProviderIsNotAvailable() {
|
||||
stopForeground(true)
|
||||
|
@ -40,10 +40,12 @@ class LocationTracker @Inject constructor(
|
||||
fun onLocationProviderIsNotAvailable()
|
||||
}
|
||||
|
||||
private var callbacks = mutableListOf<Callback>()
|
||||
private val callbacks = mutableListOf<Callback>()
|
||||
|
||||
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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 876 B |
Before Width: | Height: | Size: 638 B After Width: | Height: | Size: 594 B |
BIN
vector/src/main/res/drawable-night-hdpi/bg_no_location_map.webp
Normal file
After Width: | Height: | Size: 958 B |
BIN
vector/src/main/res/drawable-night-mdpi/bg_no_location_map.webp
Normal file
After Width: | Height: | Size: 640 B |
BIN
vector/src/main/res/drawable-night-xhdpi/bg_no_location_map.webp
Normal file
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.4 KiB |
@ -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>
|
@ -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 @@
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/locationLiveStartIcon"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginHorizontal="8dp"
|
||||
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"
|
||||
@ -38,6 +39,7 @@
|
||||
app:layout_constraintStart_toStartOf="@id/locationLiveStartBanner"
|
||||
app:layout_constraintTop_toTopOf="@id/locationLiveStartBanner"
|
||||
app:srcCompat="@drawable/ic_attachment_location_live_white"
|
||||
app:tint="?android:colorBackground"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
|
@ -21,13 +21,13 @@
|
||||
android:layout_width="51dp"
|
||||
android:layout_height="55dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
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_constraintStart_toStartOf="@id/staticMapImageView"
|
||||
app:layout_constraintTop_toTopOf="@id/staticMapImageView" />
|
||||
app:layout_constraintTop_toTopOf="@id/staticMapImageView"
|
||||
app:layout_constraintVertical_bias="1.0" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/staticMapErrorTextView"
|
||||
@ -45,4 +45,21 @@
|
||||
app:layout_constraintTop_toBottomOf="@id/staticMapPinImageView"
|
||||
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>
|
||||
|
@ -59,12 +59,24 @@
|
||||
android:layout_height="wrap_content"
|
||||
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
|
||||
android:id="@+id/messageContentLiveLocationStartStub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
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>
|
||||
|
||||
|
||||
|
@ -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>
|
@ -322,6 +322,13 @@
|
||||
<string name="start_chatting">Start Chatting</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 -->
|
||||
<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>
|
||||
@ -3006,7 +3013,13 @@
|
||||
<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_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>
|
||||
<!-- 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_description">Location sharing is in progress</string>
|
||||
|
||||
|