Merge branch 'develop' into dependency-cleanup

This commit is contained in:
Olivér Falvai 2022-05-19 12:27:09 +02:00
commit f036d35829
107 changed files with 1913 additions and 1044 deletions

View file

@ -325,5 +325,5 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hookshot_url: ${{ secrets.ELEMENT_ANDROID_HOOKSHOT_URL }}
text_template: "Post-merge validation of ${{ github.head_ref }} into ${{ github.base_ref }} by ${{ github.event.pull_request.merged_by }} failed: {{#each job_statuses }}{{#with this }}{{#if completed }} {{name}} {{conclusion}} at {{completed_at}}, {{/if}}{{/with}}{{/each}}"
html_template: "Post-merge validation of ${{ github.head_ref }} into ${{ github.base_ref }} by ${{ github.event.pull_request.merged_by }} failed: {{#each job_statuses }}{{#with this }}{{#if completed }}<br />{{icon conclusion}} {{name}} <font color='{{color conclusion}}'>{{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a></font>{{/if}}{{/with}}{{/each}}"
text_template: "Post-merge validation of ${{ github.head_ref }} into ${{ github.base_ref }} by ${{ github.event.pull_request.merged_by.login }} failed: {{#each job_statuses }}{{#with this }}{{#if completed }} {{name}} {{conclusion}} at {{completed_at}}, {{/if}}{{/with}}{{/each}}"
html_template: "Post-merge validation of ${{ github.head_ref }} into ${{ github.base_ref }} by ${{ github.event.pull_request.merged_by.login }} failed: {{#each job_statuses }}{{#with this }}{{#if completed }}<br />{{icon conclusion}} {{name}} <font color='{{color conclusion}}'>{{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a></font>{{/if}}{{/with}}{{/each}}"

1
changelog.d/5658.feature Normal file
View file

@ -0,0 +1 @@
Space explore screen changes: removed space card, added rooms filtering

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

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

1
changelog.d/5724.sdk Normal file
View file

@ -0,0 +1 @@
- Notifies other devices when a verification request sent from an Android device is accepted.`

1
changelog.d/5728.misc Normal file
View file

@ -0,0 +1 @@
leaving space experience changed to be aligned with iOS

1
changelog.d/6041.misc Normal file
View file

@ -0,0 +1 @@
Remove ShortcutBadger lib and usage (it was dead code)

1
changelog.d/6095.bugfix Normal file
View file

@ -0,0 +1 @@
Correct .well-known/matrix/client handling for server_names which include ports.

View file

@ -141,7 +141,6 @@ ext.groups = [
'jline',
'jp.wasabeef',
'junit',
'me.leolin',
'me.saket',
'net.bytebuddy',
'net.java',

View file

@ -16,6 +16,7 @@
package im.vector.lib.core.utils.flow
import android.os.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
@ -68,10 +69,10 @@ fun <T> Flow<T>.chunk(durationInMillis: Long): Flow<List<T>> {
@ExperimentalCoroutinesApi
fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
var windowStartTime = System.currentTimeMillis()
var windowStartTime = SystemClock.elapsedRealtime()
var emitted = false
collect { value ->
val currentTime = System.currentTimeMillis()
val currentTime = SystemClock.elapsedRealtime()
val delta = currentTime - windowStartTime
if (delta >= windowDuration) {
windowStartTime += delta / windowDuration * windowDuration

View file

@ -18,11 +18,11 @@ package org.billcarsonfr.jsonviewer
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.ContextMenu
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.getSystemService
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyHolder
import com.airbnb.epoxy.EpoxyModelClass
@ -77,8 +77,7 @@ internal abstract class ValueItem : EpoxyModelWithHolder<ValueItem.Holder>() {
) {
if (copyValue != null) {
val menuItem = menu?.add(R.string.copy_value)
val clipService =
v?.context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val clipService = v?.context?.getSystemService<ClipboardManager>()
menuItem?.setOnMenuItemClickListener {
clipService?.setPrimaryClip(ClipData.newPlainText("", copyValue))
true

View file

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

View file

@ -27,11 +27,13 @@ import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.UserPasswordAuth
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.session.crypto.verification.CancelCode
import org.matrix.android.sdk.api.session.crypto.verification.PendingVerificationRequest
import org.matrix.android.sdk.api.session.crypto.verification.VerificationMethod
import org.matrix.android.sdk.api.session.crypto.verification.VerificationService
import org.matrix.android.sdk.common.CommonTestHelper
import org.matrix.android.sdk.common.CryptoTestHelper
import org.matrix.android.sdk.common.SessionTestParams
import org.matrix.android.sdk.common.TestConstants
import java.util.concurrent.CountDownLatch
import kotlin.coroutines.Continuation
@ -252,4 +254,48 @@ class VerificationTest : InstrumentedTest {
cryptoTestData.cleanUp(testHelper)
}
@Test
fun test_selfVerificationAcceptedCancelsItForOtherSessions() {
val defaultSessionParams = SessionTestParams(true)
val testHelper = CommonTestHelper(context())
val aliceSessionToVerify = testHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams)
val aliceSessionThatVerifies = testHelper.logIntoAccount(aliceSessionToVerify.myUserId, TestConstants.PASSWORD, defaultSessionParams)
val aliceSessionThatReceivesCanceledEvent = testHelper.logIntoAccount(aliceSessionToVerify.myUserId, TestConstants.PASSWORD, defaultSessionParams)
val verificationMethods = listOf(VerificationMethod.SAS, VerificationMethod.QR_CODE_SCAN, VerificationMethod.QR_CODE_SHOW)
val serviceOfVerified = aliceSessionToVerify.cryptoService().verificationService()
val serviceOfVerifier = aliceSessionThatVerifies.cryptoService().verificationService()
val serviceOfUserWhoReceivesCancellation = aliceSessionThatReceivesCanceledEvent.cryptoService().verificationService()
serviceOfVerifier.addListener(object : VerificationService.Listener {
override fun verificationRequestCreated(pr: PendingVerificationRequest) {
// Accept verification request
serviceOfVerifier.readyPendingVerification(
verificationMethods,
pr.otherUserId,
pr.transactionId!!,
)
}
})
serviceOfVerified.requestKeyVerification(
methods = verificationMethods,
otherUserId = aliceSessionToVerify.myUserId,
otherDevices = listOfNotNull(aliceSessionThatVerifies.sessionParams.deviceId, aliceSessionThatReceivesCanceledEvent.sessionParams.deviceId),
)
testHelper.waitWithLatch { latch ->
testHelper.retryPeriodicallyWithLatch(latch) {
val requests = serviceOfUserWhoReceivesCancellation.getExistingVerificationRequests(aliceSessionToVerify.myUserId)
requests.any { it.cancelConclusion == CancelCode.AcceptedByAnotherDevice }
}
}
testHelper.signOutAndClose(aliceSessionToVerify)
testHelper.signOutAndClose(aliceSessionThatVerifies)
testHelper.signOutAndClose(aliceSessionThatReceivesCanceledEvent)
}
}

View file

@ -140,9 +140,24 @@ class TimelineForwardPaginationTest : InstrumentedTest {
aliceTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse()
assertEquals(EventType.STATE_ROOM_CREATE, snapshot.lastOrNull()?.root?.getClearType())
// 6 for room creation item (backward pagination), 1 for the context, and 50 for the forward pagination
// 6 + 1 + 50
assertEquals(57, snapshot.size)
// We explicitly test all the types we expect here, as we expect 51 messages and "some" state events
// But state events can change over time. So this acts as a kinda documentation of what we expect and
// provides a good error message if it doesn't match
val snapshotTypes = mutableMapOf<String?, Int>()
snapshot.groupingBy { it -> it.root.type }.eachCountTo(snapshotTypes)
// Some state events on room creation
assertEquals("m.room.name", 1, snapshotTypes.remove("m.room.name"))
assertEquals("m.room.guest_access", 1, snapshotTypes.remove("m.room.guest_access"))
assertEquals("m.room.history_visibility", 1, snapshotTypes.remove("m.room.history_visibility"))
assertEquals("m.room.join_rules", 1, snapshotTypes.remove("m.room.join_rules"))
assertEquals("m.room.power_levels", 1, snapshotTypes.remove("m.room.power_levels"))
assertEquals("m.room.create", 1, snapshotTypes.remove("m.room.create"))
assertEquals("m.room.member", 1, snapshotTypes.remove("m.room.member"))
// 50 from pagination + 1 context
assertEquals("m.room.message", 51, snapshotTypes.remove("m.room.message"))
assertEquals("Additional events found in timeline", setOf<String>(), snapshotTypes.keys)
}
// Alice paginates once again FORWARD for 50 events
@ -152,8 +167,8 @@ class TimelineForwardPaginationTest : InstrumentedTest {
val snapshot = runBlocking {
aliceTimeline.awaitPaginate(Timeline.Direction.FORWARDS, 50)
}
// 6 for room creation item (backward pagination),and numberOfMessagesToSend (all the message of the room)
snapshot.size == 6 + numberOfMessagesToSend &&
// 7 for room creation item (backward pagination),and numberOfMessagesToSend (all the message of the room)
snapshot.size == 7 + numberOfMessagesToSend &&
snapshot.checkSendOrder(message, numberOfMessagesToSend, 0)
// The timeline is fully loaded

View file

@ -74,8 +74,12 @@ class TimelinePreviousLastForwardTest : InstrumentedTest {
Timber.w(" event ${it.root}")
}
// Ok, we have the 8 first messages of the initial sync (room creation and bob invite and join events)
snapshot.size == 8
// Ok, we have the 9 first messages of the initial sync (room creation and bob invite and join events)
// create
// join alice
// power_levels, join_rules, history_visibility, guest_access, name
// invite, join bob
snapshot.size == 9
}
bobTimeline.addListener(eventsListener)
@ -192,7 +196,7 @@ class TimelinePreviousLastForwardTest : InstrumentedTest {
Timber.w(" event ${it.root}")
}
snapshot.size == 44 // 8 + 1 + 35
snapshot.size == 45 // 9 + 1 + 35
}
bobTimeline.addListener(eventsListener)
@ -220,8 +224,8 @@ class TimelinePreviousLastForwardTest : InstrumentedTest {
// Bob can see the first event of the room (so Back pagination has worked)
snapshot.lastOrNull()?.root?.getClearType() == EventType.STATE_ROOM_CREATE &&
// 8 for room creation item 60 message from Alice
snapshot.size == 68 && // 8 + 60
// 9 for room creation item 60 message from Alice
snapshot.size == 69 && // 9 + 60U
snapshot.checkSendOrder(secondMessage, 30, 0) &&
snapshot.checkSendOrder(firstMessage, 30, 30)
}

View file

@ -177,7 +177,7 @@ object MatrixPatterns {
* - "@alice:domain.org".getDomain() will return "domain.org"
* - "@bob:domain.org:3455".getDomain() will return "domain.org:3455"
*/
fun String.getDomain(): String {
fun String.getServerName(): String {
if (BuildConfig.DEBUG && !isUserId(this)) {
// They are some invalid userId localpart in the wild, but the domain part should be there anyway
Timber.w("Not a valid user ID: $this")

View file

@ -28,7 +28,8 @@ enum class CancelCode(val value: String, val humanReadable: String) {
MismatchedKeys("m.key_mismatch", "Key mismatch"),
UserError("m.user_error", "User error"),
MismatchedUser("m.user_mismatch", "User mismatch"),
QrCodeInvalid("m.qr_code.invalid", "Invalid QR code")
QrCodeInvalid("m.qr_code.invalid", "Invalid QR code"),
AcceptedByAnotherDevice("m.accepted", "Verification request accepted by another device")
}
fun safeValueOf(code: String?): CancelCode {

View file

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

View file

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

View file

@ -20,7 +20,7 @@ import android.net.Uri
import dagger.Lazy
import okhttp3.OkHttpClient
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.Credentials
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -381,7 +381,7 @@ internal class DefaultAuthenticationService @Inject constructor(
return getWellknownTask.execute(
GetWellknownTask.Params(
domain = matrixId.getDomain(),
domain = matrixId.getServerName().substringBeforeLast(":"),
homeServerConnectionConfig = homeServerConnectionConfig.orWellKnownDefaults()
)
)

View file

@ -942,6 +942,22 @@ internal class DefaultVerificationService @Inject constructor(
readyInfo = readyReq
)
)
notifyOthersOfAcceptance(readyReq.transactionId, readyReq.fromDevice)
}
/**
* Gets a list of device ids excluding the current one.
*/
private fun getMyOtherDeviceIds(): List<String> = cryptoStore.getUserDevices(userId)?.keys?.filter { it != deviceId }.orEmpty()
/**
* Notifies other devices that the current verification transaction is being handled by [acceptedByDeviceId].
*/
private fun notifyOthersOfAcceptance(transactionId: String, acceptedByDeviceId: String) {
val deviceIds = getMyOtherDeviceIds().filter { it != acceptedByDeviceId }
val transport = verificationTransportToDeviceFactory.createTransport(null)
transport.cancelTransaction(transactionId, userId, deviceIds, CancelCode.AcceptedByAnotherDevice)
}
private fun createQrCodeData(requestId: String?, otherUserId: String, otherDeviceId: String?): QrCodeData? {

View file

@ -49,6 +49,11 @@ internal interface VerificationTransport {
otherUserDeviceId: String?,
code: CancelCode)
fun cancelTransaction(transactionId: String,
otherUserId: String,
otherUserDeviceIds: List<String>,
code: CancelCode)
fun done(transactionId: String,
onDone: (() -> Unit)?)

View file

@ -160,6 +160,9 @@ internal class VerificationTransportRoomMessage(
}
}
override fun cancelTransaction(transactionId: String, otherUserId: String, otherUserDeviceIds: List<String>, code: CancelCode) =
cancelTransaction(transactionId, otherUserId, null, code)
override fun done(transactionId: String,
onDone: (() -> Unit)?) {
Timber.d("## SAS sending done for $transactionId")

View file

@ -193,6 +193,27 @@ internal class VerificationTransportToDevice(
.executeBy(taskExecutor)
}
override fun cancelTransaction(transactionId: String, otherUserId: String, otherUserDeviceIds: List<String>, code: CancelCode) {
Timber.d("## SAS canceling transaction $transactionId for reason $code")
val cancelMessage = KeyVerificationCancel.create(transactionId, code)
val contentMap = MXUsersDevicesMap<Any>()
val messages = otherUserDeviceIds.associateWith { cancelMessage }
contentMap.setObjects(otherUserId, messages)
sendToDeviceTask
.configureWith(SendToDeviceTask.Params(EventType.KEY_VERIFICATION_CANCEL, contentMap)) {
this.callback = object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
Timber.v("## SAS verification [$transactionId] canceled for reason ${code.value}")
}
override fun onFailure(failure: Throwable) {
Timber.e(failure, "## SAS verification [$transactionId] failed to cancel.")
}
}
}
.executeBy(taskExecutor)
}
override fun createAccept(tid: String,
keyAgreementProtocol: String,
hash: String,

View file

@ -87,8 +87,6 @@ import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationMan
import org.matrix.android.sdk.internal.session.openid.DefaultOpenIdService
import org.matrix.android.sdk.internal.session.permalinks.DefaultPermalinkService
import org.matrix.android.sdk.internal.session.room.EventRelationsAggregationProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.DefaultLiveLocationAggregationProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor
import org.matrix.android.sdk.internal.session.room.create.RoomCreateEventProcessor
import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcessor
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
@ -387,7 +385,4 @@ internal abstract class SessionModule {
@Binds
abstract fun bindEventSenderProcessor(processor: EventSenderProcessorCoroutine): EventSenderProcessor
@Binds
abstract fun bindLiveLocationAggregationProcessor(processor: DefaultLiveLocationAggregationProcessor): LiveLocationAggregationProcessor
}

View file

@ -17,7 +17,7 @@
package org.matrix.android.sdk.internal.session.homeserver
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
import org.matrix.android.sdk.api.extensions.orFalse
@ -93,10 +93,14 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
}
}.getOrNull()
// Domain may include a port (eg, matrix.org:8080)
// Per https://spec.matrix.org/latest/client-server-api/#well-known-uri we should extract the hostname from the server name
// So we take everything before the last : as the domain for the well-known task.
// NB: This is not always the same endpoint as capabilities / mediaConfig uses.
val wellknownResult = runCatching {
getWellknownTask.execute(
GetWellknownTask.Params(
domain = userId.getDomain(),
domain = userId.getServerName().substringBeforeLast(":"),
homeServerConnectionConfig = homeServerConnectionConfig
)
)

View file

@ -16,7 +16,7 @@
package org.matrix.android.sdk.internal.session.permalinks
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
@ -55,9 +55,9 @@ internal class ViaParameterFinder @Inject constructor(
}
fun computeViaParams(userId: String, roomId: String, max: Int): List<String> {
val userHomeserver = userId.getDomain()
val userHomeserver = userId.getServerName()
return getUserIdsOfJoinedMembers(roomId)
.map { it.getDomain() }
.map { it.getServerName() }
.groupBy { it }
.mapValues { it.value.size }
.toMutableMap()
@ -92,7 +92,7 @@ internal class ViaParameterFinder @Inject constructor(
.orEmpty()
.toSet()
return userThatCanInvite.map { it.getDomain() }
return userThatCanInvite.map { it.getServerName() }
.groupBy { it }
.mapValues { it.value.size }
.toMutableMap()

View file

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

View file

@ -1,94 +0,0 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.aggregation.livelocation
import io.realm.Realm
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.internal.database.mapper.ContentMapper
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.query.getOrCreate
import timber.log.Timber
import javax.inject.Inject
internal class DefaultLiveLocationAggregationProcessor @Inject constructor() : LiveLocationAggregationProcessor {
override fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) {
if (event.senderId.isNullOrEmpty() || isLocalEcho) {
return
}
val targetEventId = if (content.isLive.orTrue()) {
event.eventId
} else {
// when live is set to false, we use the id of the event that should have been replaced
event.unsignedData?.replacesState
}
if (targetEventId.isNullOrEmpty()) {
Timber.w("no target event id found for the beacon content")
return
}
val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
realm = realm,
roomId = roomId,
eventId = targetEventId
)
Timber.d("updating summary of id=$targetEventId with isLive=${content.isLive}")
aggregatedSummary.endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) }
aggregatedSummary.isActive = content.isLive
}
override fun handleBeaconLocationData(realm: Realm, event: Event, content: MessageBeaconLocationDataContent, roomId: String, isLocalEcho: Boolean) {
if (event.senderId.isNullOrEmpty() || isLocalEcho) {
return
}
val targetEventId = content.relatesTo?.eventId
if (targetEventId.isNullOrEmpty()) {
Timber.w("no target event id found for the live location content")
return
}
val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
realm = realm,
roomId = roomId,
eventId = targetEventId
)
val updatedLocationTimestamp = content.getBestTimestampMillis() ?: 0
val currentLocationTimestamp = ContentMapper
.map(aggregatedSummary.lastLocationContent)
.toModel<MessageBeaconLocationDataContent>()
?.getBestTimestampMillis()
?: 0
if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) {
Timber.d("updating last location of the summary of id=$targetEventId")
aggregatedSummary.lastLocationContent = ContentMapper.map(content.toContent())
}
}
private fun Long.isMoreRecentThan(timestamp: Long) = this > timestamp
}

View file

@ -17,24 +17,83 @@
package org.matrix.android.sdk.internal.session.room.aggregation.livelocation
import io.realm.Realm
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.internal.database.mapper.ContentMapper
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.query.getOrCreate
import timber.log.Timber
import javax.inject.Inject
internal interface LiveLocationAggregationProcessor {
fun handleBeaconInfo(
realm: Realm,
event: Event,
content: MessageBeaconInfoContent,
roomId: String,
isLocalEcho: Boolean,
internal class LiveLocationAggregationProcessor @Inject constructor() {
fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) {
if (event.senderId.isNullOrEmpty() || isLocalEcho) {
return
}
val targetEventId = if (content.isLive.orTrue()) {
event.eventId
} else {
// when live is set to false, we use the id of the event that should have been replaced
event.unsignedData?.replacesState
}
if (targetEventId.isNullOrEmpty()) {
Timber.w("no target event id found for the beacon content")
return
}
val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
realm = realm,
roomId = roomId,
eventId = targetEventId
)
Timber.d("updating summary of id=$targetEventId with isLive=${content.isLive}")
aggregatedSummary.endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) }
aggregatedSummary.isActive = content.isLive
}
fun handleBeaconLocationData(
realm: Realm,
event: Event,
content: MessageBeaconLocationDataContent,
roomId: String,
isLocalEcho: Boolean,
relatedEventId: String?,
isLocalEcho: Boolean
) {
if (event.senderId.isNullOrEmpty() || isLocalEcho) {
return
}
if (relatedEventId.isNullOrEmpty()) {
Timber.w("no related event id found for the live location content")
return
}
val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate(
realm = realm,
roomId = roomId,
eventId = relatedEventId
)
val updatedLocationTimestamp = content.getBestTimestampMillis() ?: 0
val currentLocationTimestamp = ContentMapper
.map(aggregatedSummary.lastLocationContent)
.toModel<MessageBeaconLocationDataContent>()
?.getBestTimestampMillis()
?: 0
if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) {
Timber.d("updating last location of the summary of id=$relatedEventId")
aggregatedSummary.lastLocationContent = ContentMapper.map(content.toContent())
}
}
private fun Long.isMoreRecentThan(timestamp: Long) = this > timestamp
}

View file

@ -16,7 +16,7 @@
package org.matrix.android.sdk.internal.session.room.alias
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.room.alias.RoomAliasError
import org.matrix.android.sdk.internal.di.UserId
@ -65,6 +65,6 @@ internal class RoomAliasAvailabilityChecker @Inject constructor(
}
companion object {
internal fun String.toFullLocalAlias(userId: String) = "#" + this + ":" + userId.getDomain()
internal fun String.toFullLocalAlias(userId: String) = "#" + this + ":" + userId.getServerName()
}
}

View file

@ -67,6 +67,9 @@ echo "Search for forbidden patterns in code..."
${searchForbiddenStringsScript} ./tools/check/forbidden_strings_in_code.txt \
./matrix-sdk-android/src/main/java \
./matrix-sdk-android-flow/src/main/java \
./library/core-utils/src/main/java \
./library/jsonviewer/src/main/java \
./library/ui-styles/src/main/java \
./vector/src/main/java \
./vector/src/debug/java \
./vector/src/release/java \
@ -100,6 +103,7 @@ echo
echo "Search for forbidden patterns in resources..."
${searchForbiddenStringsScript} ./tools/check/forbidden_strings_in_resources.txt \
./library/ui-styles/src/main/res/values \
./vector/src/main/res/color \
./vector/src/main/res/layout \
./vector/src/main/res/values \

View file

@ -450,9 +450,6 @@ dependencies {
kapt libs.github.glideCompiler
implementation 'com.github.yalantis:ucrop:2.2.8'
// Badge for compatibility
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
// Chat effects
implementation 'nl.dionsegijn:konfetti-xml:2.0.2'

View file

@ -104,11 +104,10 @@ class SpaceMenuRobot {
fun leaveSpace() {
clickOnSheet(R.id.leaveSpace)
waitUntilDialogVisible(ViewMatchers.withId(R.id.leaveButton))
clickOn(R.id.leave_selected)
waitUntilActivityVisible<SpaceLeaveAdvancedActivity> {
waitUntilViewVisible(ViewMatchers.withId(R.id.roomList))
}
clickOn(R.id.spaceLeaveSelectAll)
clickOn(R.id.spaceLeaveButton)
waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView))
}

View file

@ -1,15 +1,12 @@
/*
* Copyright 2019 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.
@ -32,7 +29,6 @@ import im.vector.app.BuildConfig
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.network.WifiDetector
import im.vector.app.core.pushers.PushersManager
import im.vector.app.features.badge.BadgeProxy
import im.vector.app.features.notifications.NotifiableEventResolver
import im.vector.app.features.notifications.NotificationDrawerManager
import im.vector.app.features.notifications.NotificationUtils
@ -152,10 +148,6 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
Timber.tag(loggerTag.value).d("## onMessageReceivedInternal()")
}
// update the badge counter
val unreadCount = data["unread"]?.let { Integer.parseInt(it) } ?: 0
BadgeProxy.updateBadgeCount(applicationContext, unreadCount)
val session = activeSessionHolder.getSafeActiveSession()
if (session == null) {

View file

@ -369,11 +369,6 @@ THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
<br/>
Copyright 2012 Square, Inc.
</li>
<li>
<b>ShortcutBadger</b>
<br/>
Copyright 2014 Leo Lin
</li>
<li>
<b>diff-match-patch</b>
<br/>

View file

@ -55,6 +55,7 @@ import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.ActivityEntryPoint
import im.vector.app.core.dialogs.DialogLocker
import im.vector.app.core.dialogs.UnrecognizedCertificateDialog
import im.vector.app.core.error.fatalError
import im.vector.app.core.extensions.observeEvent
import im.vector.app.core.extensions.observeNotNull
import im.vector.app.core.extensions.registerStartForActivityResult
@ -611,11 +612,7 @@ abstract class VectorBaseActivity<VB : ViewBinding> : AppCompatActivity(), Maver
}
}.show()
} else {
if (vectorPreferences.failFast()) {
error("No CoordinatorLayout to display this snackbar!")
} else {
Timber.w("No CoordinatorLayout to display this snackbar!")
}
fatalError("No CoordinatorLayout to display this snackbar!", vectorPreferences.failFast())
}
}

View file

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

View file

@ -19,11 +19,15 @@ package im.vector.app.core.utils
import android.content.Context
import android.os.Build
import android.text.format.Formatter
import im.vector.app.R
import org.threeten.bp.Duration
import java.util.TreeMap
object TextUtils {
private const val MINUTES_PER_HOUR = 60
private const val SECONDS_PER_MINUTE = 60
private val suffixes = TreeMap<Int, String>().also {
it[1000] = "k"
it[1000000] = "M"
@ -71,13 +75,63 @@ object TextUtils {
}
fun formatDuration(duration: Duration): String {
val hours = duration.seconds / 3600
val minutes = (duration.seconds % 3600) / 60
val seconds = duration.seconds % 60
val hours = getHours(duration)
val minutes = getMinutes(duration)
val seconds = getSeconds(duration)
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
fun formatDurationWithUnits(context: Context, duration: Duration): String {
val hours = getHours(duration)
val minutes = getMinutes(duration)
val seconds = getSeconds(duration)
val builder = StringBuilder()
when {
hours > 0 -> {
appendHours(context, builder, hours)
if (minutes > 0) {
builder.append(" ")
appendMinutes(context, builder, minutes)
}
if (seconds > 0) {
builder.append(" ")
appendSeconds(context, builder, seconds)
}
}
minutes > 0 -> {
appendMinutes(context, builder, minutes)
if (seconds > 0) {
builder.append(" ")
appendSeconds(context, builder, seconds)
}
}
else -> {
appendSeconds(context, builder, seconds)
}
}
return builder.toString()
}
private fun appendHours(context: Context, builder: StringBuilder, hours: Int) {
builder.append(hours)
builder.append(context.resources.getString(R.string.time_unit_hour_short))
}
private fun appendMinutes(context: Context, builder: StringBuilder, minutes: Int) {
builder.append(minutes)
builder.append(context.getString(R.string.time_unit_minute_short))
}
private fun appendSeconds(context: Context, builder: StringBuilder, seconds: Int) {
builder.append(seconds)
builder.append(context.getString(R.string.time_unit_second_short))
}
private fun getHours(duration: Duration): Int = duration.toHours().toInt()
private fun getMinutes(duration: Duration): Int = duration.toMinutes().toInt() % MINUTES_PER_HOUR
private fun getSeconds(duration: Duration): Int = (duration.seconds % SECONDS_PER_MINUTE).toInt()
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.core.utils
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.appbar.AppBarLayout
/**
* [AppBarLayout.Behavior] subclass with a possibility to disable behavior.
* Useful for cases when in some view state we want prevent toolbar from collapsing/expanding by scroll events
*/
class ToggleableAppBarLayoutBehavior : AppBarLayout.Behavior {
constructor() : super()
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
var isEnabled = true
override fun onStartNestedScroll(parent: CoordinatorLayout,
child: AppBarLayout,
directTargetChild: View,
target: View,
nestedScrollAxes: Int,
type: Int): Boolean {
return isEnabled && super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
}
override fun onNestedScroll(coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray) {
if (!isEnabled) return
super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed)
}
override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int) {
if (!isEnabled) return
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
}
}

View file

@ -1,129 +0,0 @@
/*
* Copyright 2019 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.
*/
@file:Suppress("UNUSED_PARAMETER")
package im.vector.app.features.badge
import android.content.Context
import android.os.Build
import me.leolin.shortcutbadger.ShortcutBadger
import org.matrix.android.sdk.api.session.Session
/**
* Manage application badge (displayed in the launcher).
*/
object BadgeProxy {
/**
* Badge is now managed by notification channel, so no need to use compatibility library in recent versions.
*
* @return true if library ShortcutBadger can be used
*/
private fun useShortcutBadger() = Build.VERSION.SDK_INT < Build.VERSION_CODES.O
/**
* Update the application badge value.
*
* @param context the context
* @param badgeValue the new badge value
*/
fun updateBadgeCount(context: Context, badgeValue: Int) {
if (!useShortcutBadger()) {
return
}
ShortcutBadger.applyCount(context, badgeValue)
}
/**
* Refresh the badge count for specific configurations.<br></br>
* The refresh is only effective if the device is:
* * offline * does not support FCM
* * FCM registration failed
* <br></br>Notifications rooms are parsed to track the notification count value.
*
* @param aSession session value
* @param aContext App context
*/
fun specificUpdateBadgeUnreadCount(aSession: Session?, aContext: Context?) {
if (!useShortcutBadger()) {
return
}
/* TODO
val dataHandler: MXDataHandler
// sanity check
if (null == aContext || null == aSession) {
Timber.w("## specificUpdateBadgeUnreadCount(): invalid input null values")
} else {
dataHandler = aSession.dataHandler
if (dataHandler == null) {
Timber.w("## specificUpdateBadgeUnreadCount(): invalid DataHandler instance")
} else {
if (aSession.isAlive) {
var isRefreshRequired: Boolean
val pushManager = Matrix.getInstance(aContext)!!.pushManager
// update the badge count if the device is offline, FCM is not supported or FCM registration failed
isRefreshRequired = !Matrix.getInstance(aContext)!!.isConnected
isRefreshRequired = isRefreshRequired or (null != pushManager && (!pushManager.useFcm() || !pushManager.hasRegistrationToken()))
if (isRefreshRequired) {
updateBadgeCount(aContext, dataHandler)
}
}
}
}
*/
}
/**
* Update the badge count value according to the rooms content.
*
* @param aContext App context
* @param aDataHandler data handler instance
*/
private fun updateBadgeCount(aSession: Session?, aContext: Context?) {
if (!useShortcutBadger()) {
return
}
/* TODO
//sanity check
if (null == aContext || null == aDataHandler) {
Timber.w("## updateBadgeCount(): invalid input null values")
} else if (null == aDataHandler.store) {
Timber.w("## updateBadgeCount(): invalid store instance")
} else {
val roomCompleteList = ArrayList(aDataHandler.store.rooms)
var unreadRoomsCount = 0
for (room in roomCompleteList) {
if (room.notificationCount > 0) {
unreadRoomsCount++
}
}
// update the badge counter
Timber.v("## updateBadgeCount(): badge update count=$unreadRoomsCount")
updateBadgeCount(aContext, unreadRoomsCount)
}
*/
}
}

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem_
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import javax.inject.Inject
class LiveLocationMessageItemFactory @Inject constructor(
private val dimensionConverter: DimensionConverter,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val avatarSizeProvider: AvatarSizeProvider,
) {
fun create(
beaconInfoContent: MessageBeaconInfoContent,
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): VectorEpoxyModel<*>? {
// TODO handle location received and stopped states
return when {
isLiveRunning(beaconInfoContent) -> buildStartLiveItem(highlight, attributes)
else -> null
}
}
private fun isLiveRunning(beaconInfoContent: MessageBeaconInfoContent): Boolean {
// TODO when we will use aggregatedSummary, check if the live has timed out as well
return beaconInfoContent.isLive.orFalse()
}
private fun buildStartLiveItem(
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): MessageLiveLocationStartItem {
val width = timelineMediaSizeProvider.getMaxSize().first
val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
return MessageLiveLocationStartItem_()
.attributes(attributes)
.mapWidth(width)
.mapHeight(height)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
}
}

View file

@ -0,0 +1,176 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.resources.DateProvider
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationInactiveItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationInactiveItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem_
import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE
import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.toLocationData
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.threeten.bp.LocalDateTime
import timber.log.Timber
import javax.inject.Inject
class LiveLocationShareMessageItemFactory @Inject constructor(
private val session: Session,
private val dimensionConverter: DimensionConverter,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val avatarSizeProvider: AvatarSizeProvider,
private val urlMapProvider: UrlMapProvider,
private val locationPinProvider: LocationPinProvider,
private val vectorDateFormatter: VectorDateFormatter,
) {
fun create(
event: TimelineEvent,
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): VectorEpoxyModel<*>? {
val liveLocationShareSummaryData = getLiveLocationShareSummaryData(event)
val item = when (val currentState = getViewState(liveLocationShareSummaryData)) {
LiveLocationShareViewState.Inactive -> buildInactiveItem(highlight, attributes)
LiveLocationShareViewState.Loading -> buildLoadingItem(highlight, attributes)
is LiveLocationShareViewState.Running -> buildRunningItem(highlight, attributes, currentState)
LiveLocationShareViewState.Unkwown -> null
}
item?.layout(attributes.informationData.messageLayout.layoutRes)
return item
}
private fun buildInactiveItem(
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): MessageLiveLocationInactiveItem {
val width = timelineMediaSizeProvider.getMaxSize().first
val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
return MessageLiveLocationInactiveItem_()
.attributes(attributes)
.mapWidth(width)
.mapHeight(height)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
}
private fun buildLoadingItem(
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): MessageLiveLocationStartItem {
val width = timelineMediaSizeProvider.getMaxSize().first
val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
return MessageLiveLocationStartItem_()
.attributes(attributes)
.mapWidth(width)
.mapHeight(height)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
}
private fun buildRunningItem(
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
runningState: LiveLocationShareViewState.Running,
): MessageLiveLocationItem {
// TODO only render location if enabled in preferences: to be handled in a next PR
val width = timelineMediaSizeProvider.getMaxSize().first
val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP)
val locationUrl = runningState.lastGeoUri.toLocationData()?.let {
urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height)
}
return MessageLiveLocationItem_()
.attributes(attributes)
.locationUrl(locationUrl)
.mapWidth(width)
.mapHeight(height)
.locationUserId(attributes.informationData.senderId)
.locationPinProvider(locationPinProvider)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.currentUserId(session.myUserId)
.endOfLiveDateTime(runningState.endOfLiveDateTime)
.vectorDateFormatter(vectorDateFormatter)
}
private fun getViewState(liveLocationShareSummaryData: LiveLocationShareSummaryData?): LiveLocationShareViewState {
return when {
liveLocationShareSummaryData?.isActive == null -> LiveLocationShareViewState.Unkwown
liveLocationShareSummaryData.isActive.not() || isLiveTimedOut(liveLocationShareSummaryData) -> LiveLocationShareViewState.Inactive
liveLocationShareSummaryData.isActive && liveLocationShareSummaryData.lastGeoUri.isNullOrEmpty() -> LiveLocationShareViewState.Loading
else ->
LiveLocationShareViewState.Running(
liveLocationShareSummaryData.lastGeoUri.orEmpty(),
getEndOfLiveDateTime(liveLocationShareSummaryData)
)
}.also { viewState -> Timber.d("computed viewState: $viewState") }
}
private fun isLiveTimedOut(liveLocationShareSummaryData: LiveLocationShareSummaryData): Boolean {
return getEndOfLiveDateTime(liveLocationShareSummaryData)
?.let { endOfLive ->
// this will only cover users with different timezones but not users with manually time set
val now = LocalDateTime.now()
now.isAfter(endOfLive)
}
.orFalse()
}
private fun getEndOfLiveDateTime(liveLocationShareSummaryData: LiveLocationShareSummaryData): LocalDateTime? {
return liveLocationShareSummaryData.endOfLiveTimestampMillis?.let { DateProvider.toLocalDateTime(timestamp = it) }
}
private fun getLiveLocationShareSummaryData(event: TimelineEvent): LiveLocationShareSummaryData? {
return event.annotations?.liveLocationShareAggregatedSummary?.let { summary ->
LiveLocationShareSummaryData(
isActive = summary.isActive,
endOfLiveTimestampMillis = summary.endOfLiveTimestampMillis,
lastGeoUri = summary.lastLocationDataContent?.getBestLocationInfo()?.geoUri
)
}
}
private data class LiveLocationShareSummaryData(
val isActive: Boolean?,
val endOfLiveTimestampMillis: Long?,
val lastGeoUri: String?,
)
private sealed class LiveLocationShareViewState {
object Loading : LiveLocationShareViewState()
data class Running(val lastGeoUri: String, val endOfLiveDateTime: LocalDateTime?) : LiveLocationShareViewState()
object Inactive : LiveLocationShareViewState()
object Unkwown : LiveLocationShareViewState()
}
}

View file

@ -148,7 +148,7 @@ class MessageItemFactory @Inject constructor(
private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider,
private val liveLocationMessageItemFactory: LiveLocationMessageItemFactory,
private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory,
) {
// TODO inject this properly?
@ -216,7 +216,7 @@ class MessageItemFactory @Inject constructor(
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
}
}
is MessageBeaconInfoContent -> liveLocationMessageItemFactory.create(messageContent, highlight, attributes)
is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes)
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
}
return messageItem?.apply {
@ -237,14 +237,14 @@ class MessageItemFactory @Inject constructor(
urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height)
}
val userId = if (locationContent.isSelfLocation()) informationData.senderId else null
val locationUserId = if (locationContent.isSelfLocation()) informationData.senderId else null
return MessageLocationItem_()
.attributes(attributes)
.locationUrl(locationUrl)
.mapWidth(width)
.mapHeight(height)
.userId(userId)
.locationUserId(locationUserId)
.locationPinProvider(locationPinProvider)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)

View file

@ -113,7 +113,8 @@ class TimelineItemFactory @Inject constructor(
EventType.CALL_NEGOTIATE,
EventType.REACTION,
in EventType.POLL_RESPONSE,
in EventType.POLL_END -> noticeItemFactory.create(params)
in EventType.POLL_END,
in EventType.BEACON_LOCATION_DATA -> noticeItemFactory.create(params)
// Calls
EventType.CALL_INVITE,
EventType.CALL_HANGUP,

View file

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

View file

@ -44,8 +44,7 @@ import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited
import javax.inject.Inject
/**
* This class compute if data of an event (such has avatar, display name, ...) should be displayed, depending on the previous event in the timeline.
* TODO Update this comment
* This class is responsible of building extra information data associated to a given event.
*/
class MessageInformationDataFactory @Inject constructor(private val session: Session,
private val dateFormatter: VectorDateFormatter,
@ -119,7 +118,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
isFirstFromThisSender = isFirstFromThisSender,
isLastFromThisSender = isLastFromThisSender,
e2eDecoration = e2eDecoration,
sendStateDecoration = sendStateDecoration
sendStateDecoration = sendStateDecoration,
)
}

View file

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

View file

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

View file

@ -0,0 +1,110 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.item
import android.graphics.drawable.Drawable
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.IdRes
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.airbnb.epoxy.EpoxyAttribute
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import im.vector.app.R
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
abstract class AbsMessageLocationItem<H : AbsMessageLocationItem.Holder> : AbsMessageItem<H>() {
@EpoxyAttribute
var locationUrl: String? = null
@EpoxyAttribute
var locationUserId: String? = null
@EpoxyAttribute
var mapWidth: Int = 0
@EpoxyAttribute
var mapHeight: Int = 0
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var locationPinProvider: LocationPinProvider? = null
override fun bind(holder: H) {
super.bind(holder)
renderSendState(holder.view, null)
bindMap(holder)
}
private fun bindMap(holder: Holder) {
val location = locationUrl ?: return
val messageLayout = attributes.informationData.messageLayout
val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
messageLayout.cornersRadius.granularRoundedCorners()
} else {
val dimensionConverter = DimensionConverter(holder.view.resources)
RoundedCorners(dimensionConverter.dpToPx(8))
}
holder.staticMapImageView.updateLayoutParams {
width = mapWidth
height = mapHeight
}
GlideApp.with(holder.staticMapImageView)
.load(location)
.apply(RequestOptions.centerCropTransform())
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean): Boolean {
holder.staticMapPinImageView.setImageResource(R.drawable.ic_location_pin_failed)
holder.staticMapErrorTextView.isVisible = true
return false
}
override fun onResourceReady(resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean): Boolean {
locationPinProvider?.create(locationUserId) { pinDrawable ->
// we are not using Glide since it does not display it correctly when there is no user photo
holder.staticMapPinImageView.setImageDrawable(pinDrawable)
}
holder.staticMapErrorTextView.isVisible = false
return false
}
})
.transform(imageCornerTransformation)
.into(holder.staticMapImageView)
}
abstract class Holder(@IdRes stubId: Int) : AbsMessageItem.Holder(stubId) {
val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
val staticMapErrorTextView by bind<TextView>(R.id.staticMapErrorTextView)
}
}

View file

@ -0,0 +1,80 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.item
import android.content.res.Resources
import android.graphics.drawable.ColorDrawable
import android.widget.ImageView
import androidx.core.view.updateLayoutParams
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import im.vector.app.R
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
import im.vector.app.features.themes.ThemeUtils
/**
* Default implementation of common methods for item representing the status of a live location share.
*/
class DefaultLiveLocationShareStatusItem : LiveLocationShareStatusItem {
override fun bindMap(
mapImageView: ImageView,
mapWidth: Int,
mapHeight: Int,
messageLayout: TimelineMessageLayout
) {
val mapCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
messageLayout.cornersRadius.granularRoundedCorners()
} else {
RoundedCorners(getDefaultLayoutCornerRadiusInDp(mapImageView.resources))
}
mapImageView.updateLayoutParams {
width = mapWidth
height = mapHeight
}
GlideApp.with(mapImageView)
.load(R.drawable.bg_no_location_map)
.transform(mapCornerTransformation)
.into(mapImageView)
}
override fun bindBottomBanner(bannerImageView: ImageView, messageLayout: TimelineMessageLayout) {
val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
GranularRoundedCorners(
0f,
0f,
messageLayout.cornersRadius.bottomEndRadius,
messageLayout.cornersRadius.bottomStartRadius
)
} else {
val bottomCornerRadius = getDefaultLayoutCornerRadiusInDp(bannerImageView.resources).toFloat()
GranularRoundedCorners(0f, 0f, bottomCornerRadius, bottomCornerRadius)
}
GlideApp.with(bannerImageView)
.load(ColorDrawable(ThemeUtils.getColor(bannerImageView.context, android.R.attr.colorBackground)))
.transform(imageCornerTransformation)
.into(bannerImageView)
}
private fun getDefaultLayoutCornerRadiusInDp(resources: Resources): Int {
val dimensionConverter = DimensionConverter(resources)
return dimensionConverter.dpToPx(8)
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.item
import android.widget.ImageView
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
interface LiveLocationShareStatusItem {
fun bindMap(
mapImageView: ImageView,
mapWidth: Int,
mapHeight: Int,
messageLayout: TimelineMessageLayout
)
fun bindBottomBanner(bannerImageView: ImageView, messageLayout: TimelineMessageLayout)
}

View file

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

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.item
import android.widget.ImageView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLiveLocationInactiveItem :
AbsMessageItem<MessageLiveLocationInactiveItem.Holder>(),
LiveLocationShareStatusItem by DefaultLiveLocationShareStatusItem() {
@EpoxyAttribute
var mapWidth: Int = 0
@EpoxyAttribute
var mapHeight: Int = 0
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.view, null)
bindMap(holder.noLocationMapImageView, mapWidth, mapHeight, attributes.informationData.messageLayout)
bindBottomBanner(holder.bannerImageView, attributes.informationData.messageLayout)
}
override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val bannerImageView by bind<ImageView>(R.id.locationLiveInactiveBanner)
val noLocationMapImageView by bind<ImageView>(R.id.locationLiveInactiveMap)
}
companion object {
private const val STUB_ID = R.id.messageContentLiveLocationInactiveStub
}
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.item
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.resources.toTimestamp
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.location.live.LocationLiveMessageBannerView
import im.vector.app.features.location.live.LocationLiveMessageBannerViewState
import org.threeten.bp.LocalDateTime
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLiveLocationItem : AbsMessageLocationItem<MessageLiveLocationItem.Holder>() {
@EpoxyAttribute
var currentUserId: String? = null
@EpoxyAttribute
var endOfLiveDateTime: LocalDateTime? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
lateinit var vectorDateFormatter: VectorDateFormatter
override fun bind(holder: Holder) {
super.bind(holder)
bindLocationLiveBanner(holder)
}
private fun bindLocationLiveBanner(holder: Holder) {
// TODO in a future PR add check on device id to confirm that is the one that sent the beacon
val isEmitter = currentUserId != null && currentUserId == locationUserId
val messageLayout = attributes.informationData.messageLayout
val viewState = buildViewState(holder, messageLayout, isEmitter)
holder.locationLiveMessageBanner.isVisible = true
holder.locationLiveMessageBanner.render(viewState)
holder.locationLiveMessageBanner.stopButton.setOnClickListener {
attributes.callback?.onTimelineItemAction(RoomDetailAction.StopLiveLocationSharing)
}
}
private fun buildViewState(
holder: Holder,
messageLayout: TimelineMessageLayout,
isEmitter: Boolean
): LocationLiveMessageBannerViewState {
return when {
messageLayout is TimelineMessageLayout.Bubble && isEmitter ->
LocationLiveMessageBannerViewState.Emitter(
remainingTimeInMillis = getRemainingTimeOfLiveInMillis(),
bottomStartCornerRadiusInDp = messageLayout.cornersRadius.bottomStartRadius,
bottomEndCornerRadiusInDp = messageLayout.cornersRadius.bottomEndRadius,
isStopButtonCenteredVertically = false
)
messageLayout is TimelineMessageLayout.Bubble ->
LocationLiveMessageBannerViewState.Watcher(
bottomStartCornerRadiusInDp = messageLayout.cornersRadius.bottomStartRadius,
bottomEndCornerRadiusInDp = messageLayout.cornersRadius.bottomEndRadius,
formattedLocalTimeOfEndOfLive = getFormattedLocalTimeEndOfLive(),
)
isEmitter -> {
val cornerRadius = getBannerCornerRadiusForDefaultLayout(holder)
LocationLiveMessageBannerViewState.Emitter(
remainingTimeInMillis = getRemainingTimeOfLiveInMillis(),
bottomStartCornerRadiusInDp = cornerRadius,
bottomEndCornerRadiusInDp = cornerRadius,
isStopButtonCenteredVertically = true
)
}
else -> {
val cornerRadius = getBannerCornerRadiusForDefaultLayout(holder)
LocationLiveMessageBannerViewState.Watcher(
bottomStartCornerRadiusInDp = cornerRadius,
bottomEndCornerRadiusInDp = cornerRadius,
formattedLocalTimeOfEndOfLive = getFormattedLocalTimeEndOfLive(),
)
}
}
}
private fun getBannerCornerRadiusForDefaultLayout(holder: Holder): Float {
val dimensionConverter = DimensionConverter(holder.view.resources)
return dimensionConverter.dpToPx(8).toFloat()
}
private fun getFormattedLocalTimeEndOfLive() =
endOfLiveDateTime?.toTimestamp()?.let { vectorDateFormatter.format(it, DateFormatKind.MESSAGE_SIMPLE) }.orEmpty()
private fun getRemainingTimeOfLiveInMillis() =
(endOfLiveDateTime?.toTimestamp() ?: 0) - LocalDateTime.now().toTimestamp()
override fun getViewStubId() = STUB_ID
class Holder : AbsMessageLocationItem.Holder(STUB_ID) {
val locationLiveMessageBanner by bind<LocationLiveMessageBannerView>(R.id.locationLiveMessageBanner)
}
companion object {
private const val STUB_ID = R.id.messageContentLiveLocationStub
}
}

View file

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

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2021 New Vector Ltd
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,97 +16,15 @@
package im.vector.app.features.home.room.detail.timeline.item
import android.graphics.drawable.Drawable
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import im.vector.app.R
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>() {
@EpoxyAttribute
var locationUrl: String? = null
@EpoxyAttribute
var userId: String? = null
@EpoxyAttribute
var mapWidth: Int = 0
@EpoxyAttribute
var mapHeight: Int = 0
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var locationPinProvider: LocationPinProvider? = null
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.view, null)
val location = locationUrl ?: return
val messageLayout = attributes.informationData.messageLayout
val dimensionConverter = DimensionConverter(holder.view.resources)
val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) {
messageLayout.cornersRadius.granularRoundedCorners()
} else {
RoundedCorners(dimensionConverter.dpToPx(8))
}
holder.staticMapImageView.updateLayoutParams {
width = mapWidth
height = mapHeight
}
GlideApp.with(holder.staticMapImageView)
.load(location)
.apply(RequestOptions.centerCropTransform())
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean): Boolean {
holder.staticMapPinImageView.setImageResource(R.drawable.ic_location_pin_failed)
holder.staticMapErrorTextView.isVisible = true
return false
}
override fun onResourceReady(resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean): Boolean {
locationPinProvider?.create(userId) { pinDrawable ->
GlideApp.with(holder.staticMapPinImageView)
.load(pinDrawable)
.into(holder.staticMapPinImageView)
}
holder.staticMapErrorTextView.isVisible = false
return false
}
})
.transform(imageCornerTransformation)
.into(holder.staticMapImageView)
}
abstract class MessageLocationItem : AbsMessageLocationItem<MessageLocationItem.Holder>() {
override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
val staticMapErrorTextView by bind<TextView>(R.id.staticMapErrorTextView)
}
class Holder : AbsMessageLocationItem.Holder(STUB_ID)
companion object {
private const val STUB_ID = R.id.messageContentLocationStub

View file

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

View file

@ -0,0 +1,27 @@
/*
* Copyright 2019 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.list
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = R.layout.item_space_directory_filter_no_results)
abstract class SpaceDirectoryFilterNoResults : VectorEpoxyModel<SpaceDirectoryFilterNoResults.Holder>() {
class Holder : VectorEpoxyHolder()
}

View file

@ -22,5 +22,5 @@ const val DEFAULT_PIN_ID = "DEFAULT_PIN_ID"
const val INITIAL_MAP_ZOOM_IN_PREVIEW = 15.0
const val INITIAL_MAP_ZOOM_IN_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

View file

@ -29,7 +29,7 @@ data class LocationData(
) : Parcelable
/**
* Creates location data from a LocationContent.
* Creates location data from a MessageLocationContent.
* "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* @return location data or null if geo uri is not valid
*/
@ -37,6 +37,15 @@ fun MessageLocationContent.toLocationData(): LocationData? {
return parseGeo(getBestGeoUri())
}
/**
* Creates location data from a geoUri String.
* "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* @return location data or null if geo uri is null or not valid
*/
fun String?.toLocationData(): LocationData? {
return this?.let { parseGeo(it) }
}
@VisibleForTesting
fun parseGeo(geo: String): LocationData? {
val geoParts = geo

View file

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

View file

@ -40,10 +40,12 @@ class LocationTracker @Inject constructor(
fun onLocationProviderIsNotAvailable()
}
private var callbacks = mutableListOf<Callback>()
private val callbacks = mutableListOf<Callback>()
private var hasGpsProviderLiveLocation = false
private var lastLocation: LocationData? = null
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
Timber.d("## LocationTracker. start()")
@ -92,6 +94,14 @@ class LocationTracker @Inject constructor(
callbacks.clear()
}
/**
* Request the last known location. It will be given async through Callback.
* Please ensure adding a callback to receive the value.
*/
fun requestLastKnownLocation() {
lastLocation?.let { location -> callbacks.forEach { it.onLocationUpdate(location) } }
}
fun addCallback(callback: Callback) {
if (!callbacks.contains(callback)) {
callbacks.add(callback)
@ -127,7 +137,9 @@ class LocationTracker @Inject constructor(
}
}
}
callbacks.forEach { it.onLocationUpdate(location.toLocationData()) }
val locationData = location.toLocationData()
lastLocation = locationData
callbacks.forEach { it.onLocationUpdate(locationData) }
}
override fun onProviderDisabled(provider: String) {

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location.live
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.os.CountDownTimer
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isVisible
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners
import im.vector.app.R
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.utils.TextUtils
import im.vector.app.databinding.ViewLocationLiveMessageBannerBinding
import im.vector.app.features.themes.ThemeUtils
import org.threeten.bp.Duration
private const val REMAINING_TIME_COUNTER_INTERVAL_IN_MS = 1000L
class LocationLiveMessageBannerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewLocationLiveMessageBannerBinding.inflate(
LayoutInflater.from(context),
this
)
val stopButton: Button
get() = binding.locationLiveMessageBannerStop
private val background: ImageView
get() = binding.locationLiveMessageBannerBackground
private val title: TextView
get() = binding.locationLiveMessageBannerTitle
private val subTitle: TextView
get() = binding.locationLiveMessageBannerSubTitle
private var countDownTimer: CountDownTimer? = null
fun render(viewState: LocationLiveMessageBannerViewState) {
when (viewState) {
is LocationLiveMessageBannerViewState.Emitter -> renderEmitter(viewState)
is LocationLiveMessageBannerViewState.Watcher -> renderWatcher(viewState)
}
GlideApp.with(context)
.load(ColorDrawable(ThemeUtils.getColor(context, android.R.attr.colorBackground)))
.transform(GranularRoundedCorners(0f, 0f, viewState.bottomEndCornerRadiusInDp, viewState.bottomStartCornerRadiusInDp))
.into(background)
}
private fun renderEmitter(viewState: LocationLiveMessageBannerViewState.Emitter) {
stopButton.isVisible = true
title.text = context.getString(R.string.location_share_live_enabled)
countDownTimer?.cancel()
viewState.remainingTimeInMillis
.takeIf { it >= 0 }
?.let {
countDownTimer = object : CountDownTimer(it, REMAINING_TIME_COUNTER_INTERVAL_IN_MS) {
override fun onTick(millisUntilFinished: Long) {
val duration = Duration.ofMillis(millisUntilFinished.coerceAtLeast(0L))
subTitle.text = context.getString(
R.string.location_share_live_remaining_time,
TextUtils.formatDurationWithUnits(context, duration)
)
}
override fun onFinish() {
subTitle.text = context.getString(
R.string.location_share_live_remaining_time,
TextUtils.formatDurationWithUnits(context, Duration.ofMillis(0L))
)
}
}
countDownTimer?.start()
}
val rootLayout: ConstraintLayout? = (binding.root as? ConstraintLayout)
rootLayout?.let { parentLayout ->
val constraintSet = ConstraintSet()
constraintSet.clone(rootLayout)
if (viewState.isStopButtonCenteredVertically) {
constraintSet.connect(
R.id.locationLiveMessageBannerStop,
ConstraintSet.BOTTOM,
R.id.locationLiveMessageBannerBackground,
ConstraintSet.BOTTOM,
0
)
} else {
constraintSet.clear(R.id.locationLiveMessageBannerStop, ConstraintSet.BOTTOM)
}
constraintSet.applyTo(parentLayout)
}
}
private fun renderWatcher(viewState: LocationLiveMessageBannerViewState.Watcher) {
stopButton.isVisible = false
title.text = context.getString(R.string.location_share_live_view)
subTitle.text = context.getString(R.string.location_share_live_until, viewState.formattedLocalTimeOfEndOfLive)
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location.live
sealed class LocationLiveMessageBannerViewState(
open val bottomStartCornerRadiusInDp: Float,
open val bottomEndCornerRadiusInDp: Float,
) {
data class Emitter(
override val bottomStartCornerRadiusInDp: Float,
override val bottomEndCornerRadiusInDp: Float,
val remainingTimeInMillis: Long,
val isStopButtonCenteredVertically: Boolean
) : LocationLiveMessageBannerViewState(bottomStartCornerRadiusInDp, bottomEndCornerRadiusInDp)
data class Watcher(
override val bottomStartCornerRadiusInDp: Float,
override val bottomEndCornerRadiusInDp: Float,
val formattedLocalTimeOfEndOfLive: String,
) : LocationLiveMessageBannerViewState(bottomStartCornerRadiusInDp, bottomEndCornerRadiusInDp)
}

View file

@ -36,7 +36,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ensureTrailingSlash
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -607,7 +607,7 @@ class LoginViewModel @AssistedInject constructor(
identityServerUri = wellKnownPrompt.identityServerUrl?.let { Uri.parse(it) }
)
?: HomeServerConnectionConfig(
homeServerUri = Uri.parse("https://${action.username.getDomain()}"),
homeServerUri = Uri.parse("https://${action.username.getServerName()}"),
homeServerUriBase = Uri.parse(wellKnownPrompt.homeServerUrl),
identityServerUri = wellKnownPrompt.identityServerUrl?.let { Uri.parse(it) }
)

View file

@ -38,7 +38,7 @@ import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ReAuthHelper
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -640,7 +640,7 @@ class LoginViewModel2 @AssistedInject constructor(
}
viewEvent?.let { _viewEvents.post(it) }
val urlFromUser = action.username.getDomain()
val urlFromUser = action.username.getServerName()
setState {
copy(
isLoading = false,

View file

@ -317,6 +317,7 @@ class DefaultNavigator @Inject constructor(
if (context is AppCompatActivity) {
if (context !is MatrixToBottomSheet.InteractionListener) {
fatalError("Caller context should implement MatrixToBottomSheet.InteractionListener", vectorPreferences.failFast())
return
}
// TODO check if there is already one??
MatrixToBottomSheet.withLink(link, origin)

View file

@ -20,7 +20,7 @@ import im.vector.app.R
import im.vector.app.core.extensions.andThen
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.onboarding.OnboardingAction.LoginOrRegister
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
@ -75,7 +75,7 @@ class DirectLoginUseCase @Inject constructor(
)
private fun fallbackConfig(action: LoginOrRegister, wellKnownPrompt: WellknownResult.Prompt) = HomeServerConnectionConfig(
homeServerUri = uriFactory.parse("https://${action.username.getDomain()}"),
homeServerUri = uriFactory.parse("https://${action.username.getServerName()}"),
homeServerUriBase = uriFactory.parse(wellKnownPrompt.homeServerUrl),
identityServerUri = wellKnownPrompt.identityServerUrl?.let { uriFactory.parse(it) }
)

View file

@ -16,14 +16,14 @@
package im.vector.app.features.raw.wellknown
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.data.SessionParams
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.raw.RawService
suspend fun RawService.getElementWellknown(sessionParams: SessionParams): ElementWellKnown? {
// By default we use the domain of the userId to retrieve the .well-known data
val domain = sessionParams.userId.getDomain()
val domain = sessionParams.userId.getServerName()
return tryOrNull { getWellknown(domain) }
?.let { ElementWellKnownMapper.from(it) }
}

View file

@ -35,7 +35,7 @@ import im.vector.app.features.raw.wellknown.getElementWellknown
import im.vector.app.features.raw.wellknown.isE2EByDefault
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.raw.RawService
@ -98,7 +98,7 @@ class CreateRoomViewModel @AssistedInject constructor(
private fun initHomeServerName() {
setState {
copy(
homeServerName = session.myUserId.getDomain()
homeServerName = session.myUserId.getServerName()
)
}
}

View file

@ -20,7 +20,7 @@ import im.vector.app.R
import im.vector.app.core.resources.StringArrayProvider
import im.vector.app.features.roomdirectory.RoomDirectoryData
import im.vector.app.features.roomdirectory.RoomDirectoryServer
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import javax.inject.Inject
@ -37,7 +37,7 @@ class RoomDirectoryListCreator @Inject constructor(
val protocols = ArrayList<RoomDirectoryData>()
// Add user homeserver name
val userHsName = session.myUserId.getDomain()
val userHsName = session.myUserId.getServerName()
// Add default protocol
protocols.add(

View file

@ -31,7 +31,7 @@ import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.EventType
@ -96,7 +96,7 @@ class RoomAliasViewModel @AssistedInject constructor(@Assisted initialState: Roo
private fun initHomeServerName() {
setState {
copy(
homeServerName = session.myUserId.getDomain()
homeServerName = session.myUserId.getServerName()
)
}
}

View file

@ -1,194 +0,0 @@
/*
* 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.spaces
import android.app.Activity
import android.graphics.Typeface
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.toSpannable
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.args
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.utils.styleMatchingText
import im.vector.app.databinding.BottomSheetLeaveSpaceBinding
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.spaces.leave.SpaceLeaveAdvancedActivity
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import me.gujun.android.span.span
import org.matrix.android.sdk.api.util.toMatrixItem
import reactivecircus.flowbinding.android.widget.checkedChanges
import javax.inject.Inject
@AndroidEntryPoint
class LeaveSpaceBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetLeaveSpaceBinding>() {
val settingsViewModel: SpaceMenuViewModel by parentFragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetLeaveSpaceBinding {
return BottomSheetLeaveSpaceBinding.inflate(inflater, container, false)
}
@Inject lateinit var colorProvider: ColorProvider
@Inject lateinit var errorFormatter: ErrorFormatter
@Parcelize
data class Args(
val spaceId: String
) : Parcelable
override val showExpanded = true
private val spaceArgs: SpaceBottomSheetSettingsArgs by args()
private val cherryPickLeaveActivityResult = registerStartForActivityResult { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
// nothing actually?
} else {
// move back to default
settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveAll)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.autoLeaveRadioGroup.checkedChanges()
.onEach {
when (it) {
views.leaveAll.id -> {
settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveAll)
}
views.leaveNone.id -> {
settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveNone)
}
views.leaveSelected.id -> {
settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveSelected)
// launch dedicated activity
cherryPickLeaveActivityResult.launch(
SpaceLeaveAdvancedActivity.newIntent(requireContext(), spaceArgs.spaceId)
)
}
}
}
.launchIn(viewLifecycleOwner.lifecycleScope)
views.leaveButton.debouncedClicks {
settingsViewModel.handle(SpaceLeaveViewAction.LeaveSpace)
}
views.cancelButton.debouncedClicks {
dismiss()
}
}
override fun invalidate() = withState(settingsViewModel) { state ->
super.invalidate()
val spaceSummary = state.spaceSummary ?: return@withState
val bestName = spaceSummary.toMatrixItem().getBestName()
val commonText = getString(R.string.space_leave_prompt_msg_with_name, bestName)
.toSpannable().styleMatchingText(bestName, Typeface.BOLD)
val warningMessage: CharSequence = if (spaceSummary.otherMemberIds.isEmpty()) {
span {
+commonText
+"\n\n"
span(getString(R.string.space_leave_prompt_msg_only_you)) {
textColor = colorProvider.getColorFromAttribute(R.attr.colorError)
}
}
} else if (state.isLastAdmin) {
span {
+commonText
+"\n\n"
span(getString(R.string.space_leave_prompt_msg_as_admin)) {
textColor = colorProvider.getColorFromAttribute(R.attr.colorError)
}
}
} else if (!spaceSummary.isPublic) {
span {
+commonText
+"\n\n"
span(getString(R.string.space_leave_prompt_msg_private)) {
textColor = colorProvider.getColorFromAttribute(R.attr.colorError)
}
}
} else {
commonText
}
views.bottomLeaveSpaceWarningText.setTextOrHide(warningMessage)
views.inlineErrorText.setTextOrHide(null)
if (state.leavingState is Loading) {
views.leaveButton.isInvisible = true
views.cancelButton.isInvisible = true
views.leaveProgress.isVisible = true
} else {
views.leaveButton.isInvisible = false
views.cancelButton.isInvisible = false
views.leaveProgress.isVisible = false
if (state.leavingState is Fail) {
views.inlineErrorText.setTextOrHide(errorFormatter.toHumanReadable(state.leavingState.error))
}
}
val hasChildren = (spaceSummary.spaceChildren?.size ?: 0) > 0
if (hasChildren) {
views.autoLeaveRadioGroup.isVisible = true
when (state.leaveMode) {
SpaceMenuState.LeaveMode.LEAVE_ALL -> {
views.autoLeaveRadioGroup.check(views.leaveAll.id)
}
SpaceMenuState.LeaveMode.LEAVE_NONE -> {
views.autoLeaveRadioGroup.check(views.leaveNone.id)
}
SpaceMenuState.LeaveMode.LEAVE_SELECTED -> {
views.autoLeaveRadioGroup.check(views.leaveSelected.id)
}
}
} else {
views.autoLeaveRadioGroup.isVisible = false
}
}
companion object {
fun newInstance(spaceId: String): LeaveSpaceBottomSheet {
return LeaveSpaceBottomSheet().apply {
setArguments(SpaceBottomSheetSettingsArgs(spaceId))
}
}
}
}

View file

@ -35,6 +35,7 @@ import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.navigation.Navigator
import im.vector.app.features.rageshake.BugReporter
import im.vector.app.features.roomprofile.RoomProfileActivity
import im.vector.app.features.spaces.leave.SpaceLeaveAdvancedActivity
import im.vector.app.features.spaces.manage.ManageType
import im.vector.app.features.spaces.manage.SpaceManageActivity
import kotlinx.parcelize.Parcelize
@ -109,7 +110,7 @@ class SpaceSettingsMenuBottomSheet : VectorBaseBottomSheetDialogFragment<BottomS
}
views.leaveSpace.views.bottomSheetActionClickableZone.debouncedClicks {
LeaveSpaceBottomSheet.newInstance(spaceArgs.spaceId).show(childFragmentManager, "LOGOUT")
startActivity(SpaceLeaveAdvancedActivity.newIntent(requireContext(), spaceArgs.spaceId))
}
}

View file

@ -35,7 +35,7 @@ import im.vector.app.core.resources.StringProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.identity.IdentityServiceListener
import org.matrix.android.sdk.api.session.room.AliasAvailabilityResult
@ -66,7 +66,7 @@ class CreateSpaceViewModel @AssistedInject constructor(
val identityServerUrl = identityService.getCurrentIdentityServerUrl()
setState {
copy(
homeServerName = session.myUserId.getDomain(),
homeServerName = session.myUserId.getServerName(),
canInviteByMail = identityServerUrl != null
)
}

View file

@ -34,6 +34,8 @@ import im.vector.app.core.ui.list.genericEmptyWithActionItem
import im.vector.app.core.ui.list.genericPillItem
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.list.spaceChildInfoItem
import im.vector.app.features.home.room.list.spaceDirectoryFilterNoResults
import im.vector.app.features.spaces.manage.SpaceChildInfoMatchFilter
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import me.gujun.android.span.span
import org.matrix.android.sdk.api.extensions.orFalse
@ -53,6 +55,7 @@ class SpaceDirectoryController @Inject constructor(
) : TypedEpoxyController<SpaceDirectoryState>() {
interface InteractionListener {
fun onFilterQueryChanged(query: String?)
fun onButtonClick(spaceChildInfo: SpaceChildInfo)
fun onSpaceChildClick(spaceChildInfo: SpaceChildInfo)
fun onRoomClick(spaceChildInfo: SpaceChildInfo)
@ -62,6 +65,7 @@ class SpaceDirectoryController @Inject constructor(
}
var listener: InteractionListener? = null
private val matchFilter = SpaceChildInfoMatchFilter()
override fun buildModels(data: SpaceDirectoryState?) {
val host = this
@ -76,7 +80,7 @@ class SpaceDirectoryController @Inject constructor(
val failure = results.error
if (failure is Failure.ServerError && failure.error.code == M_UNRECOGNIZED) {
genericPillItem {
id("HS no Support")
id("hs_no_support")
imageRes(R.drawable.error)
tintIcon(false)
text(
@ -132,7 +136,15 @@ class SpaceDirectoryController @Inject constructor(
}
}
} else {
flattenChildInfo.forEach { info ->
matchFilter.filter = data?.currentFilter ?: ""
val filteredChildInfo = flattenChildInfo.filter { matchFilter.test(it) }
if (filteredChildInfo.isEmpty()) {
spaceDirectoryFilterNoResults {
id("no_results")
}
} else {
filteredChildInfo.forEach { info ->
val isSpace = info.roomType == RoomType.SPACE
val isJoined = data?.joinedRoomsIds?.contains(info.childRoomId) == true
val isLoading = data?.changeMembershipStates?.get(info.childRoomId)?.isInProgress() ?: false
@ -172,6 +184,7 @@ class SpaceDirectoryController @Inject constructor(
}
}
}
}
if (hierarchySummary?.nextToken != null) {
val paginationStatus = data.paginationStatus[currentRootId] ?: Uninitialized
if (paginationStatus is Fail) {

View file

@ -23,6 +23,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.text.toSpannable
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
@ -44,7 +45,6 @@ import im.vector.app.core.utils.openUrlInExternalBrowser
import im.vector.app.databinding.FragmentSpaceDirectoryBinding
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.matrixto.SpaceCardRenderer
import im.vector.app.features.permalink.PermalinkHandler
import im.vector.app.features.spaces.manage.ManageType
import im.vector.app.features.spaces.manage.SpaceAddRoomSpaceChooserBottomSheet
@ -63,7 +63,6 @@ data class SpaceDirectoryArgs(
class SpaceDirectoryFragment @Inject constructor(
private val epoxyController: SpaceDirectoryController,
private val permalinkHandler: PermalinkHandler,
private val spaceCardRenderer: SpaceCardRenderer,
private val colorProvider: ColorProvider
) : VectorBaseFragment<FragmentSpaceDirectoryBinding>(),
SpaceDirectoryController.InteractionListener,
@ -123,9 +122,6 @@ class SpaceDirectoryFragment @Inject constructor(
}
}
views.spaceCard.matrixToCardMainButton.isVisible = false
views.spaceCard.matrixToCardSecondaryButton.isVisible = false
// Hide FAB when list is scrolling
views.spaceDirectoryList.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
@ -167,18 +163,37 @@ class SpaceDirectoryFragment @Inject constructor(
// it's the root
toolbar?.setTitle(R.string.space_explore_activity_title)
} else {
toolbar?.title = state.currentRootSummary?.name
val spaceName = state.currentRootSummary?.name
?: state.currentRootSummary?.canonicalAlias
?: getString(R.string.space_explore_activity_title)
if (spaceName != null) {
toolbar?.title = spaceName
toolbar?.subtitle = getString(R.string.space_explore_activity_title)
} else {
toolbar?.title = getString(R.string.space_explore_activity_title)
}
}
spaceCardRenderer.render(state.currentRootSummary, emptyList(), this, views.spaceCard, showDescription = false)
views.addOrCreateChatRoomButton.isVisible = state.canAddRooms
}
override fun onPrepareOptionsMenu(menu: Menu) = withState(viewModel) { state ->
menu.findItem(R.id.spaceAddRoom)?.isVisible = state.canAddRooms
menu.findItem(R.id.spaceCreateRoom)?.isVisible = false // Not yet implemented
menu.findItem(R.id.spaceSearch)?.let { searchItem ->
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
onFilterQueryChanged(newText)
return true
}
})
}
super.onPrepareOptionsMenu(menu)
}
@ -198,6 +213,10 @@ class SpaceDirectoryFragment @Inject constructor(
return super.onOptionsItemSelected(item)
}
override fun onFilterQueryChanged(query: String?) {
viewModel.handle(SpaceDirectoryViewAction.FilterRooms(query))
}
override fun onButtonClick(spaceChildInfo: SpaceChildInfo) {
viewModel.handle(SpaceDirectoryViewAction.JoinOrOpen(spaceChildInfo))
}

View file

@ -22,6 +22,7 @@ import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo
sealed class SpaceDirectoryViewAction : VectorViewModelAction {
data class ExploreSubSpace(val spaceChildInfo: SpaceChildInfo) : SpaceDirectoryViewAction()
data class JoinOrOpen(val spaceChildInfo: SpaceChildInfo) : SpaceDirectoryViewAction()
data class FilterRooms(val query: String?) : SpaceDirectoryViewAction()
data class ShowDetails(val spaceChildInfo: SpaceChildInfo) : SpaceDirectoryViewAction()
data class NavigateToRoom(val roomId: String) : SpaceDirectoryViewAction()
object CreateNewRoom : SpaceDirectoryViewAction()

View file

@ -225,8 +225,15 @@ class SpaceDirectoryViewModel @AssistedInject constructor(
_viewEvents.post(SpaceDirectoryViewEvents.NavigateToCreateNewRoom(state.currentRootSummary?.roomId ?: initialState.spaceId))
}
}
is SpaceDirectoryViewAction.FilterRooms -> {
filter(action.query)
}
}
}
private fun filter(query: String?) {
setState { copy(currentFilter = query.orEmpty()) }
}
private fun handleBack() = withState { state ->
if (state.hierarchyStack.isEmpty()) {

View file

@ -21,6 +21,9 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class SpaceLeaveAdvanceViewAction : VectorViewModelAction {
data class ToggleSelection(val roomId: String) : SpaceLeaveAdvanceViewAction()
data class UpdateFilter(val filter: String) : SpaceLeaveAdvanceViewAction()
data class SetFilteringEnabled(val isEnabled: Boolean) : SpaceLeaveAdvanceViewAction()
object DoLeave : SpaceLeaveAdvanceViewAction()
object ClearError : SpaceLeaveAdvanceViewAction()
object SelectAll : SpaceLeaveAdvanceViewAction()
object SelectNone : SpaceLeaveAdvanceViewAction()
}

View file

@ -28,8 +28,11 @@ data class SpaceLeaveAdvanceViewState(
val allChildren: Async<List<RoomSummary>> = Uninitialized,
val selectedRooms: List<String> = emptyList(),
val currentFilter: String = "",
val leaveState: Async<Unit> = Uninitialized
val leaveState: Async<Unit> = Uninitialized,
val isFilteringEnabled: Boolean = false,
val isLastAdmin: Boolean = false
) : MavericksState {
constructor(args: SpaceBottomSheetSettingsArgs) : this(
spaceId = args.spaceId
)

View file

@ -18,20 +18,23 @@ package im.vector.app.features.spaces.leave
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import androidx.appcompat.widget.SearchView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.isVisible
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.ToggleableAppBarLayoutBehavior
import im.vector.app.databinding.FragmentSpaceLeaveAdvancedBinding
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import reactivecircus.flowbinding.appcompat.queryTextChanges
import javax.inject.Inject
class SpaceLeaveAdvancedFragment @Inject constructor(
@ -44,11 +47,33 @@ class SpaceLeaveAdvancedFragment @Inject constructor(
val viewModel: SpaceLeaveAdvancedViewModel by activityViewModel()
override fun getMenuRes() = R.menu.menu_space_leave
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar(views.toolbar)
.allowBack()
controller.listener = this
withState(viewModel) { state ->
setupToolbar(views.toolbar)
.setSubtitle(state.spaceSummary?.name)
.allowBack()
state.spaceSummary?.let { summary ->
val warningMessage: CharSequence? = when {
summary.otherMemberIds.isEmpty() -> getString(R.string.space_leave_prompt_msg_only_you)
state.isLastAdmin -> getString(R.string.space_leave_prompt_msg_as_admin)
!summary.isPublic -> getString(R.string.space_leave_prompt_msg_private)
else -> null
}
views.spaceLeavePromptDescription.isVisible = warningMessage != null
views.spaceLeavePromptDescription.text = warningMessage
}
views.spaceLeavePromptTitle.text = getString(R.string.space_leave_prompt_msg_with_name, state.spaceSummary?.name ?: "")
}
views.roomList.configureWith(controller)
views.spaceLeaveCancel.debouncedClicks { requireActivity().finish() }
@ -56,12 +81,23 @@ class SpaceLeaveAdvancedFragment @Inject constructor(
viewModel.handle(SpaceLeaveAdvanceViewAction.DoLeave)
}
views.publicRoomsFilter.queryTextChanges()
.debounce(100)
.onEach {
viewModel.handle(SpaceLeaveAdvanceViewAction.UpdateFilter(it.toString()))
views.spaceLeaveSelectGroup.setOnCheckedChangeListener { _, optionId ->
when (optionId) {
R.id.spaceLeaveSelectAll -> viewModel.handle(SpaceLeaveAdvanceViewAction.SelectAll)
R.id.spaceLeaveSelectNone -> viewModel.handle(SpaceLeaveAdvanceViewAction.SelectNone)
}
.launchIn(viewLifecycleOwner.lifecycleScope)
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.menu_space_leave_search)?.let { searchItem ->
searchItem.bind(
onExpanded = { viewModel.handle(SpaceLeaveAdvanceViewAction.SetFilteringEnabled(isEnabled = true)) },
onCollapsed = { viewModel.handle(SpaceLeaveAdvanceViewAction.SetFilteringEnabled(isEnabled = false)) },
onTextChanged = { viewModel.handle(SpaceLeaveAdvanceViewAction.UpdateFilter(it)) }
)
}
super.onPrepareOptionsMenu(menu)
}
override fun onDestroyView() {
@ -72,10 +108,63 @@ class SpaceLeaveAdvancedFragment @Inject constructor(
override fun invalidate() = withState(viewModel) { state ->
super.invalidate()
if (state.isFilteringEnabled) {
views.appBarLayout.setExpanded(false)
}
updateAppBarBehaviorState(state)
updateRadioButtonsState(state)
controller.setData(state)
}
override fun onItemSelected(roomSummary: RoomSummary) {
viewModel.handle(SpaceLeaveAdvanceViewAction.ToggleSelection(roomSummary.roomId))
}
private fun updateAppBarBehaviorState(state: SpaceLeaveAdvanceViewState) {
val behavior = (views.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams).behavior as ToggleableAppBarLayoutBehavior
behavior.isEnabled = !state.isFilteringEnabled
}
private fun updateRadioButtonsState(state: SpaceLeaveAdvanceViewState) {
(state.allChildren as? Success)?.invoke()?.size?.let { allChildrenCount ->
when (state.selectedRooms.size) {
0 -> views.spaceLeaveSelectNone.isChecked = true
allChildrenCount -> views.spaceLeaveSelectAll.isChecked = true
else -> views.spaceLeaveSelectSemi.isChecked = true
}
}
}
private fun MenuItem.bind(
onExpanded: () -> Unit,
onCollapsed: () -> Unit,
onTextChanged: (String) -> Unit) {
setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
onExpanded()
return true
}
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
onCollapsed()
return true
}
})
val searchView = actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
onTextChanged(newText ?: "")
return true
}
})
}
}

View file

@ -36,9 +36,14 @@ import okhttp3.internal.toImmutableList
import org.matrix.android.sdk.api.query.ActiveSpaceFilter
import org.matrix.android.sdk.api.query.RoomCategoryFilter
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.getRoomSummary
import org.matrix.android.sdk.api.session.room.getStateEvent
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
import org.matrix.android.sdk.api.session.room.powerlevels.Role
import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams
import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.unwrap
@ -50,52 +55,24 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor(
private val appStateHandler: AppStateHandler
) : VectorViewModel<SpaceLeaveAdvanceViewState, SpaceLeaveAdvanceViewAction, EmptyViewEvents>(initialState) {
override fun handle(action: SpaceLeaveAdvanceViewAction) = withState { state ->
when (action) {
is SpaceLeaveAdvanceViewAction.ToggleSelection -> {
val existing = state.selectedRooms.toMutableList()
if (existing.contains(action.roomId)) {
existing.remove(action.roomId)
} else {
existing.add(action.roomId)
}
setState {
copy(
selectedRooms = existing.toImmutableList()
)
}
}
is SpaceLeaveAdvanceViewAction.UpdateFilter -> {
setState { copy(currentFilter = action.filter) }
}
SpaceLeaveAdvanceViewAction.DoLeave -> {
setState { copy(leaveState = Loading()) }
viewModelScope.launch {
try {
state.selectedRooms.forEach {
try {
session.roomService().leaveRoom(it)
} catch (failure: Throwable) {
// silently ignore?
Timber.e(failure, "Fail to leave sub rooms/spaces")
}
}
session.spaceService().leaveSpace(initialState.spaceId)
// We observe the membership and to dismiss when we have remote echo of leaving
} catch (failure: Throwable) {
setState { copy(leaveState = Fail(failure)) }
}
}
}
SpaceLeaveAdvanceViewAction.ClearError -> {
setState { copy(leaveState = Uninitialized) }
}
}
}
init {
val spaceSummary = session.getRoomSummary(initialState.spaceId)
val space = session.getRoom(initialState.spaceId)
val spaceSummary = space?.roomSummary()
val powerLevelsEvent = space?.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS)
powerLevelsEvent?.content?.toModel<PowerLevelsContent>()?.let { powerLevelsContent ->
val powerLevelsHelper = PowerLevelsHelper(powerLevelsContent)
val isAdmin = powerLevelsHelper.getUserRole(session.myUserId) is Role.Admin
val otherAdminCount = spaceSummary?.otherMemberIds
?.map { powerLevelsHelper.getUserRole(it) }
?.count { it is Role.Admin }
?: 0
val isLastAdmin = isAdmin && otherAdminCount == 0
setState {
copy(isLastAdmin = isLastAdmin)
}
}
setState { copy(spaceSummary = spaceSummary) }
session.getRoom(initialState.spaceId)?.let { room ->
room.flow().liveRoomSummary()
@ -127,6 +104,62 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor(
}
}
override fun handle(action: SpaceLeaveAdvanceViewAction) {
when (action) {
is SpaceLeaveAdvanceViewAction.UpdateFilter -> setState { copy(currentFilter = action.filter) }
SpaceLeaveAdvanceViewAction.ClearError -> setState { copy(leaveState = Uninitialized) }
SpaceLeaveAdvanceViewAction.SelectNone -> setState { copy(selectedRooms = emptyList()) }
is SpaceLeaveAdvanceViewAction.SetFilteringEnabled -> setState { copy(isFilteringEnabled = action.isEnabled) }
is SpaceLeaveAdvanceViewAction.ToggleSelection -> handleSelectionToggle(action)
SpaceLeaveAdvanceViewAction.DoLeave -> handleLeave()
SpaceLeaveAdvanceViewAction.SelectAll -> handleSelectAll()
}
}
private fun handleSelectAll() = withState { state ->
val filteredRooms = (state.allChildren as? Success)?.invoke()?.filter {
it.name.contains(state.currentFilter, true)
}
filteredRooms?.let {
setState { copy(selectedRooms = it.map { it.roomId }) }
}
}
private fun handleLeave() = withState { state ->
setState { copy(leaveState = Loading()) }
viewModelScope.launch {
try {
state.selectedRooms.forEach {
try {
session.roomService().leaveRoom(it)
} catch (failure: Throwable) {
// silently ignore?
Timber.e(failure, "Fail to leave sub rooms/spaces")
}
}
session.spaceService().leaveSpace(initialState.spaceId)
// We observe the membership and to dismiss when we have remote echo of leaving
} catch (failure: Throwable) {
setState { copy(leaveState = Fail(failure)) }
}
}
}
private fun handleSelectionToggle(action: SpaceLeaveAdvanceViewAction.ToggleSelection) = withState { state ->
val existing = state.selectedRooms.toMutableList()
if (existing.contains(action.roomId)) {
existing.remove(action.roomId)
} else {
existing.add(action.roomId)
}
setState {
copy(
selectedRooms = existing.toImmutableList(),
)
}
}
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<SpaceLeaveAdvancedViewModel, SpaceLeaveAdvanceViewState> {
override fun create(initialState: SpaceLeaveAdvanceViewState): SpaceLeaveAdvancedViewModel

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -1,105 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:orientation="vertical">
<TextView
android:id="@+id/bottom_leave_space_warning_text"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="20dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginBottom="8dp"
android:textColor="?vctr_content_primary"
tools:text="@string/space_leave_prompt_msg_with_name" />
<RadioGroup
android:id="@+id/autoLeaveRadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="?dialogPreferredPadding"
android:paddingTop="12dp"
android:paddingEnd="?dialogPreferredPadding"
android:paddingBottom="12dp">
<RadioButton
android:id="@+id/leave_all"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/leave_all_rooms_and_spaces"
tools:checked="true" />
<RadioButton
android:id="@+id/leave_none"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minWidth="180dp"
android:text="@string/dont_leave_any" />
<RadioButton
android:id="@+id/leave_selected"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minWidth="180dp"
android:text="@string/leave_specific_ones" />
</RadioGroup>
<TextView
android:id="@+id/inlineErrorText"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="4dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:textColor="?colorError"
tools:visibility="visible"
tools:text="@string/error_no_network"
android:visibility="gone" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="16dp">
<ProgressBar
android:id="@+id/leaveProgress"
style="?android:attr/progressBarStyle"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
<Button
android:id="@+id/leaveButton"
style="@style/Widget.Vector.Button.Destructive"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_gravity="center_horizontal"
android:text="@string/leave_space" />
</FrameLayout>
<Button
android:id="@+id/cancelButton"
style="@style/Widget.Vector.Button.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:text="@string/action_cancel" />
</LinearLayout>

View file

@ -11,35 +11,12 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/spaceExploreCollapsingToolbarLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:contentScrim="?android:colorBackground"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
app:scrimAnimationDuration="250"
app:scrimVisibleHeightTrigger="120dp"
app:titleEnabled="false"
app:toolbarId="@id/toolbar">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="40dp">
<include
android:id="@+id/spaceCard"
layout="@layout/fragment_matrix_to_room_space_card" />
</FrameLayout>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_collapseMode="pin" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
android:layout_height="?attr/actionBarSize"
app:contentInsetStart="0dp">
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
@ -57,7 +34,7 @@
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp "
android:layout_marginBottom="16dp"
android:contentDescription="@string/a11y_create_room"
android:scaleType="center"
android:src="@drawable/ic_fab_add"

View file

@ -16,41 +16,107 @@
tools:listitem="@layout/item_room_to_add_in_space" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
app:layout_behavior="im.vector.app.core.utils.ToggleableAppBarLayoutBehavior">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/spaceExploreCollapsingToolbarLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:contentScrim="?android:colorBackground"
app:layout_scrollFlags="scroll|exitUntilCollapsed|enterAlways|snap"
app:scrimAnimationDuration="250"
app:scrimVisibleHeightTrigger="120dp"
app:titleEnabled="false"
app:toolbarId="@id/toolbar">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="?attr/actionBarSize"
android:minHeight="0dp"
android:orientation="vertical"
android:paddingHorizontal="16dp">
<TextView
android:id="@+id/spaceLeavePromptTitle"
style="@style/TextAppearance.Vector.Body.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/space_leave_prompt_msg_with_name" />
<TextView
android:id="@+id/spaceLeavePromptDescription"
style="@style/TextAppearance.Vector.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/space_leave_prompt_msg_only_you"
android:textColor="?vctr_content_secondary" />
<TextView
android:id="@+id/spaceLeaveRadioButtonsTitle"
style="@style/TextAppearance.Vector.Subtitle.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/space_leave_radio_buttons_title"
android:textAllCaps="true"
android:textColor="?vctr_content_primary" />
<RadioGroup
android:id="@+id/spaceLeaveSelectGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<androidx.appcompat.widget.AppCompatRadioButton
android:id="@+id/spaceLeaveSelectAll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/space_leave_radio_button_all" />
<androidx.appcompat.widget.AppCompatRadioButton
android:id="@+id/spaceLeaveSelectNone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/space_leave_radio_button_none" />
<!-- This view should never be visible! There are three possible states but only two buttons by design.-->
<!-- Third button is needed to make radiogroup work as expected, it's selected, but never shown-->
<androidx.appcompat.widget.AppCompatRadioButton
android:id="@+id/spaceLeaveSelectSemi"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
</RadioGroup>
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- minHeight="0dp" is important to collapse on scroll -->
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:minHeight="0dp"
app:title="@string/pick_tings_to_leave"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap|enterAlways"/>
<androidx.appcompat.widget.SearchView
android:id="@+id/publicRoomsFilter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/addRoomToSpaceToolbar"
app:queryHint="@string/search_hint_room_name" />
app:layout_collapseMode="pin"
app:title="Leave space" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:id="@+id/spacePreviewButtonBar"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?vctr_system"
android:background="@color/palette_white"
android:elevation="2dp"
android:orientation="horizontal"
android:padding="8dp">
android:padding="8dp"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior">
<Button
android:id="@+id/spaceLeaveCancel"
@ -68,7 +134,6 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/leave_space" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -136,8 +136,8 @@
android:layout_height="1dp"
android:background="?vctr_list_separator_system"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="@id/joinSuggestedRoomButton"
app:layout_constraintStart_toStartOf="@id/roomNameView"
app:layout_constraintTop_toBottomOf="@id/inlineErrorText" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/space_explore_rooms_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:foreground="?attr/selectableItemBackground"
android:orientation="vertical"
android:paddingHorizontal="32dp"
android:paddingTop="16dp"
tools:viewBindingIgnore="true">
<TextView
style="@style/Widget.Vector.TextView.Body.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/space_explore_filter_no_result_title" />
<TextView
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/space_explore_filter_no_result_description"
android:textColor="?vctr_content_secondary" />
<TextView
style="@style/Widget.Vector.TextView.Body.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/create_new_room"
android:textColor="?colorSecondary" />
</androidx.appcompat.widget.LinearLayoutCompat>

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more