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>
|
<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>
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
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
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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 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)
|
||||||
|
@ -113,7 +113,8 @@ 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,
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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 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
|
||||||
|
@ -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
|
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
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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,19 +190,13 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
room
|
|
||||||
.stateService()
|
|
||||||
.getLiveLocationBeaconInfo(userId, true)
|
|
||||||
?.eventId
|
|
||||||
?.let {
|
|
||||||
room.sendService().sendLiveLocation(
|
room.sendService().sendLiveLocation(
|
||||||
beaconInfoEventId = it,
|
beaconInfoEventId = beaconInfoEventId,
|
||||||
latitude = locationData.latitude,
|
latitude = locationData.latitude,
|
||||||
longitude = locationData.longitude,
|
longitude = locationData.longitude,
|
||||||
uncertainty = locationData.uncertainty
|
uncertainty = locationData.uncertainty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLocationProviderIsNotAvailable() {
|
override fun onLocationProviderIsNotAvailable() {
|
||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
|
@ -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) {
|
||||||
|
@ -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: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
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
@ -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="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>
|
||||||
|
|
||||||
|