diff --git a/.github/workflows/post-pr.yml b/.github/workflows/post-pr.yml index 9cd33143ad..8fe51eb8d5 100644 --- a/.github/workflows/post-pr.yml +++ b/.github/workflows/post-pr.yml @@ -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 }}
{{icon conclusion}} {{name}} {{conclusion}} at {{completed_at}} [details]{{/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 }}
{{icon conclusion}} {{name}} {{conclusion}} at {{completed_at}} [details]{{/if}}{{/with}}{{/each}}" diff --git a/changelog.d/5658.feature b/changelog.d/5658.feature new file mode 100644 index 0000000000..ba41a03207 --- /dev/null +++ b/changelog.d/5658.feature @@ -0,0 +1 @@ +Space explore screen changes: removed space card, added rooms filtering diff --git a/changelog.d/5689.wip b/changelog.d/5689.wip new file mode 100644 index 0000000000..ccea1ec541 --- /dev/null +++ b/changelog.d/5689.wip @@ -0,0 +1 @@ +[Live location sharing] Update message in timeline during the live diff --git a/changelog.d/5724.sdk b/changelog.d/5724.sdk new file mode 100644 index 0000000000..5a0a37fe31 --- /dev/null +++ b/changelog.d/5724.sdk @@ -0,0 +1 @@ +- Notifies other devices when a verification request sent from an Android device is accepted.` diff --git a/changelog.d/5728.misc b/changelog.d/5728.misc new file mode 100644 index 0000000000..6e463fa76f --- /dev/null +++ b/changelog.d/5728.misc @@ -0,0 +1 @@ +leaving space experience changed to be aligned with iOS diff --git a/changelog.d/6041.misc b/changelog.d/6041.misc new file mode 100644 index 0000000000..50378ea3fd --- /dev/null +++ b/changelog.d/6041.misc @@ -0,0 +1 @@ +Remove ShortcutBadger lib and usage (it was dead code) diff --git a/changelog.d/6095.bugfix b/changelog.d/6095.bugfix new file mode 100644 index 0000000000..11110bfa08 --- /dev/null +++ b/changelog.d/6095.bugfix @@ -0,0 +1 @@ +Correct .well-known/matrix/client handling for server_names which include ports. diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index 76869fccf1..59cefe7e89 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -141,7 +141,6 @@ ext.groups = [ 'jline', 'jp.wasabeef', 'junit', - 'me.leolin', 'me.saket', 'net.bytebuddy', 'net.java', diff --git a/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt b/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt index 2efb439ace..aeb5ae7914 100644 --- a/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt +++ b/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt @@ -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 Flow.chunk(durationInMillis: Long): Flow> { @ExperimentalCoroutinesApi fun Flow.throttleFirst(windowDuration: Long): Flow = 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 diff --git a/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt b/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt index fac7099b37..66dfcc5dc3 100644 --- a/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt +++ b/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt @@ -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() { ) { 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() menuItem?.setOnMenuItemClickListener { clipService?.setPrimaryClip(ClipData.newPlainText("", copyValue)) true diff --git a/library/ui-styles/src/main/res/values/styles_location.xml b/library/ui-styles/src/main/res/values/styles_location.xml index 5563d28342..7571265241 100644 --- a/library/ui-styles/src/main/res/values/styles_location.xml +++ b/library/ui-styles/src/main/res/values/styles_location.xml @@ -2,10 +2,20 @@ + + diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt index df3b2ffe27..ceebc3cd01 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt @@ -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) + } } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt index d5b4a07fc0..e407c1b42d 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt @@ -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() + 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(), 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 diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt index 6e5fed8df9..1a36adec44 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt @@ -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) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixPatterns.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixPatterns.kt index 867e066e60..82f39806c0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixPatterns.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixPatterns.kt @@ -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") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/verification/CancelCode.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/verification/CancelCode.kt index 5a025f37e1..e4716d7794 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/verification/CancelCode.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/verification/CancelCode.kt @@ -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 { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 16bdbd3432..7124d8a1a3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent @@ -375,11 +376,11 @@ fun Event.getRelationContent(): RelationDefaultContent? { content.toModel()?.relatesTo } else { content.toModel()?.relatesTo ?: run { - // Special case to handle stickers, while there is only a local msgtype for stickers - if (getClearType() == EventType.STICKER) { - getClearContent().toModel()?.relatesTo - } else { - null + // Special cases when there is only a local msgtype for some event types + when (getClearType()) { + EventType.STICKER -> getClearContent().toModel()?.relatesTo + in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel()?.relatesTo + else -> null } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index b87bc25435..d05fdb951f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent @@ -140,6 +141,7 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? { EventType.STICKER -> root.getClearContent().toModel() in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() + in EventType.BEACON_LOCATION_DATA -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt index f1cfe3fee5..02dfce04b5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt @@ -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() ) ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt index 6da674d6e4..af48283767 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt @@ -939,9 +939,25 @@ internal class DefaultVerificationService @Inject constructor( updatePendingRequest( existingRequest.copy( - readyInfo = readyReq + readyInfo = readyReq ) ) + + notifyOthersOfAcceptance(readyReq.transactionId, readyReq.fromDevice) + } + + /** + * Gets a list of device ids excluding the current one. + */ + private fun getMyOtherDeviceIds(): List = 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? { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransport.kt index c12aea9d52..8538e5a5af 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransport.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransport.kt @@ -49,6 +49,11 @@ internal interface VerificationTransport { otherUserDeviceId: String?, code: CancelCode) + fun cancelTransaction(transactionId: String, + otherUserId: String, + otherUserDeviceIds: List, + code: CancelCode) + fun done(transactionId: String, onDone: (() -> Unit)?) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportRoomMessage.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportRoomMessage.kt index e32828af23..03df849d22 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportRoomMessage.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportRoomMessage.kt @@ -160,6 +160,9 @@ internal class VerificationTransportRoomMessage( } } + override fun cancelTransaction(transactionId: String, otherUserId: String, otherUserDeviceIds: List, code: CancelCode) = + cancelTransaction(transactionId, otherUserId, null, code) + override fun done(transactionId: String, onDone: (() -> Unit)?) { Timber.d("## SAS sending done for $transactionId") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportToDevice.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportToDevice.kt index bc24ef2966..974adf3888 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportToDevice.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationTransportToDevice.kt @@ -193,6 +193,27 @@ internal class VerificationTransportToDevice( .executeBy(taskExecutor) } + override fun cancelTransaction(transactionId: String, otherUserId: String, otherUserDeviceIds: List, code: CancelCode) { + Timber.d("## SAS canceling transaction $transactionId for reason $code") + val cancelMessage = KeyVerificationCancel.create(transactionId, code) + val contentMap = MXUsersDevicesMap() + val messages = otherUserDeviceIds.associateWith { cancelMessage } + contentMap.setObjects(otherUserId, messages) + sendToDeviceTask + .configureWith(SendToDeviceTask.Params(EventType.KEY_VERIFICATION_CANCEL, contentMap)) { + this.callback = object : MatrixCallback { + 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, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt index 7ceb89e892..b5f49d7f9c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt @@ -87,8 +87,6 @@ import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationMan import org.matrix.android.sdk.internal.session.openid.DefaultOpenIdService import org.matrix.android.sdk.internal.session.permalinks.DefaultPermalinkService import org.matrix.android.sdk.internal.session.room.EventRelationsAggregationProcessor -import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.DefaultLiveLocationAggregationProcessor -import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor import org.matrix.android.sdk.internal.session.room.create.RoomCreateEventProcessor import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor @@ -387,7 +385,4 @@ internal abstract class SessionModule { @Binds abstract fun bindEventSenderProcessor(processor: EventSenderProcessorCoroutine): EventSenderProcessor - - @Binds - abstract fun bindLiveLocationAggregationProcessor(processor: DefaultLiveLocationAggregationProcessor): LiveLocationAggregationProcessor } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt index e9097e4d03..b1a518724c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt @@ -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 ) ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/ViaParameterFinder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/ViaParameterFinder.kt index 0f667c65df..edc45fe945 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/ViaParameterFinder.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/ViaParameterFinder.kt @@ -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 { - 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() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index 16a63a9a96..af9c0071fe 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -193,9 +193,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } } in EventType.BEACON_LOCATION_DATA -> { - event.getClearContent().toModel(catchError = true)?.let { - liveLocationAggregationProcessor.handleBeaconLocationData(realm, event, it, roomId, isLocalEcho) - } + handleBeaconLocationData(event, realm, roomId, isLocalEcho) } } } else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) { @@ -260,6 +258,9 @@ internal class EventRelationsAggregationProcessor @Inject constructor( liveLocationAggregationProcessor.handleBeaconInfo(realm, event, it, roomId, isLocalEcho) } } + in EventType.BEACON_LOCATION_DATA -> { + handleBeaconLocationData(event, realm, roomId, isLocalEcho) + } else -> Timber.v("UnHandled event ${event.eventId}") } } catch (t: Throwable) { @@ -756,4 +757,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor( verifSummary.sourceEvents.add(event.eventId) } } + + private fun handleBeaconLocationData(event: Event, realm: Realm, roomId: String, isLocalEcho: Boolean) { + event.getClearContent().toModel(catchError = true)?.let { + liveLocationAggregationProcessor.handleBeaconLocationData( + realm, + event, + it, + roomId, + event.getRelationContent()?.eventId, + isLocalEcho + ) + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/DefaultLiveLocationAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/DefaultLiveLocationAggregationProcessor.kt deleted file mode 100644 index 997e31a109..0000000000 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/DefaultLiveLocationAggregationProcessor.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2022 The Matrix.org Foundation C.I.C. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.matrix.android.sdk.internal.session.room.aggregation.livelocation - -import io.realm.Realm -import org.matrix.android.sdk.api.extensions.orTrue -import org.matrix.android.sdk.api.session.events.model.Event -import org.matrix.android.sdk.api.session.events.model.toContent -import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent -import org.matrix.android.sdk.internal.database.mapper.ContentMapper -import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity -import org.matrix.android.sdk.internal.database.query.getOrCreate -import timber.log.Timber -import javax.inject.Inject - -internal class DefaultLiveLocationAggregationProcessor @Inject constructor() : LiveLocationAggregationProcessor { - - override fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) { - if (event.senderId.isNullOrEmpty() || isLocalEcho) { - return - } - - val targetEventId = if (content.isLive.orTrue()) { - event.eventId - } else { - // when live is set to false, we use the id of the event that should have been replaced - event.unsignedData?.replacesState - } - - if (targetEventId.isNullOrEmpty()) { - Timber.w("no target event id found for the beacon content") - return - } - - val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate( - realm = realm, - roomId = roomId, - eventId = targetEventId - ) - - Timber.d("updating summary of id=$targetEventId with isLive=${content.isLive}") - - aggregatedSummary.endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) } - aggregatedSummary.isActive = content.isLive - } - - override fun handleBeaconLocationData(realm: Realm, event: Event, content: MessageBeaconLocationDataContent, roomId: String, isLocalEcho: Boolean) { - if (event.senderId.isNullOrEmpty() || isLocalEcho) { - return - } - - val targetEventId = content.relatesTo?.eventId - - if (targetEventId.isNullOrEmpty()) { - Timber.w("no target event id found for the live location content") - return - } - - val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate( - realm = realm, - roomId = roomId, - eventId = targetEventId - ) - val updatedLocationTimestamp = content.getBestTimestampMillis() ?: 0 - val currentLocationTimestamp = ContentMapper - .map(aggregatedSummary.lastLocationContent) - .toModel() - ?.getBestTimestampMillis() - ?: 0 - - if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) { - Timber.d("updating last location of the summary of id=$targetEventId") - aggregatedSummary.lastLocationContent = ContentMapper.map(content.toContent()) - } - } - - private fun Long.isMoreRecentThan(timestamp: Long) = this > timestamp -} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt index c0be96f83d..76b7a4ec8e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt @@ -17,24 +17,83 @@ package org.matrix.android.sdk.internal.session.room.aggregation.livelocation import io.realm.Realm +import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent +import org.matrix.android.sdk.internal.database.mapper.ContentMapper +import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.query.getOrCreate +import timber.log.Timber +import javax.inject.Inject -internal interface LiveLocationAggregationProcessor { - fun handleBeaconInfo( - realm: Realm, - event: Event, - content: MessageBeaconInfoContent, - roomId: String, - isLocalEcho: Boolean, - ) +internal class LiveLocationAggregationProcessor @Inject constructor() { + + fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) { + if (event.senderId.isNullOrEmpty() || isLocalEcho) { + return + } + + val targetEventId = if (content.isLive.orTrue()) { + event.eventId + } else { + // when live is set to false, we use the id of the event that should have been replaced + event.unsignedData?.replacesState + } + + if (targetEventId.isNullOrEmpty()) { + Timber.w("no target event id found for the beacon content") + return + } + + val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate( + realm = realm, + roomId = roomId, + eventId = targetEventId + ) + + Timber.d("updating summary of id=$targetEventId with isLive=${content.isLive}") + + aggregatedSummary.endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) } + aggregatedSummary.isActive = content.isLive + } fun handleBeaconLocationData( realm: Realm, event: Event, content: MessageBeaconLocationDataContent, roomId: String, - isLocalEcho: Boolean, - ) + relatedEventId: String?, + isLocalEcho: Boolean + ) { + if (event.senderId.isNullOrEmpty() || isLocalEcho) { + return + } + + if (relatedEventId.isNullOrEmpty()) { + Timber.w("no related event id found for the live location content") + return + } + + val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate( + realm = realm, + roomId = roomId, + eventId = relatedEventId + ) + val updatedLocationTimestamp = content.getBestTimestampMillis() ?: 0 + val currentLocationTimestamp = ContentMapper + .map(aggregatedSummary.lastLocationContent) + .toModel() + ?.getBestTimestampMillis() + ?: 0 + + if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) { + Timber.d("updating last location of the summary of id=$relatedEventId") + aggregatedSummary.lastLocationContent = ContentMapper.map(content.toContent()) + } + } + + private fun Long.isMoreRecentThan(timestamp: Long) = this > timestamp } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt index 7c137a8102..fa19b4f9cf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt @@ -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() } } diff --git a/tools/check/check_code_quality.sh b/tools/check/check_code_quality.sh index e40d3635e8..910616176c 100755 --- a/tools/check/check_code_quality.sh +++ b/tools/check/check_code_quality.sh @@ -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 \ diff --git a/vector/build.gradle b/vector/build.gradle index 1c0a4718c4..99ced285cc 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -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' diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt index 4d35e3c550..289c6e21b4 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt @@ -104,11 +104,10 @@ class SpaceMenuRobot { fun leaveSpace() { clickOnSheet(R.id.leaveSpace) - waitUntilDialogVisible(ViewMatchers.withId(R.id.leaveButton)) - clickOn(R.id.leave_selected) waitUntilActivityVisible { waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) } + clickOn(R.id.spaceLeaveSelectAll) clickOn(R.id.spaceLeaveButton) waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView)) } diff --git a/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt b/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt index b62520278a..a7d814052a 100755 --- a/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt +++ b/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt @@ -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) { diff --git a/vector/src/main/assets/open_source_licenses.html b/vector/src/main/assets/open_source_licenses.html index 0bead1f826..8f27776fbf 100755 --- a/vector/src/main/assets/open_source_licenses.html +++ b/vector/src/main/assets/open_source_licenses.html @@ -369,11 +369,6 @@ THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Copyright 2012 Square, Inc. -
  • - ShortcutBadger -
    - Copyright 2014 Leo Lin -
  • diff-match-patch
    diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt index d904a5a4de..8a4aaa4b26 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt @@ -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 : 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()) } } diff --git a/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt b/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt index 30cb1dcae4..6762bd68da 100644 --- a/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/DateProvider.kt @@ -19,27 +19,30 @@ package im.vector.app.core.resources import org.threeten.bp.Instant import org.threeten.bp.LocalDateTime import org.threeten.bp.ZoneId +import org.threeten.bp.ZoneOffset object DateProvider { - private val zoneId = ZoneId.systemDefault() - private val zoneOffset by lazy { - val now = currentLocalDateTime() - zoneId.rules.getOffset(now) - } + // recompute the zoneId each time we access it to handle change of timezones + private val defaultZoneId: ZoneId + get() = ZoneId.systemDefault() + + // recompute the zoneOffset each time we access it to handle change of timezones + private val defaultZoneOffset: ZoneOffset + get() = defaultZoneId.rules.getOffset(currentLocalDateTime()) fun toLocalDateTime(timestamp: Long?): LocalDateTime { val instant = Instant.ofEpochMilli(timestamp ?: 0) - return LocalDateTime.ofInstant(instant, zoneId) + return LocalDateTime.ofInstant(instant, defaultZoneId) } fun currentLocalDateTime(): LocalDateTime { val instant = Instant.now() - return LocalDateTime.ofInstant(instant, zoneId) + return LocalDateTime.ofInstant(instant, defaultZoneId) } fun toTimestamp(localDateTime: LocalDateTime): Long { - return localDateTime.toInstant(zoneOffset).toEpochMilli() + return localDateTime.toInstant(defaultZoneOffset).toEpochMilli() } } diff --git a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt index 992a85679c..d2f8c4022b 100644 --- a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt +++ b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt @@ -19,11 +19,15 @@ package im.vector.app.core.utils import android.content.Context import android.os.Build import android.text.format.Formatter +import im.vector.app.R import org.threeten.bp.Duration import java.util.TreeMap object TextUtils { + private const val MINUTES_PER_HOUR = 60 + private const val SECONDS_PER_MINUTE = 60 + private val suffixes = TreeMap().also { it[1000] = "k" it[1000000] = "M" @@ -71,13 +75,63 @@ object TextUtils { } fun formatDuration(duration: Duration): String { - val hours = duration.seconds / 3600 - val minutes = (duration.seconds % 3600) / 60 - val seconds = duration.seconds % 60 + val hours = getHours(duration) + val minutes = getMinutes(duration) + val seconds = getSeconds(duration) return if (hours > 0) { String.format("%d:%02d:%02d", hours, minutes, seconds) } else { String.format("%02d:%02d", minutes, seconds) } } + + fun formatDurationWithUnits(context: Context, duration: Duration): String { + val hours = getHours(duration) + val minutes = getMinutes(duration) + val seconds = getSeconds(duration) + val builder = StringBuilder() + when { + hours > 0 -> { + appendHours(context, builder, hours) + if (minutes > 0) { + builder.append(" ") + appendMinutes(context, builder, minutes) + } + if (seconds > 0) { + builder.append(" ") + appendSeconds(context, builder, seconds) + } + } + minutes > 0 -> { + appendMinutes(context, builder, minutes) + if (seconds > 0) { + builder.append(" ") + appendSeconds(context, builder, seconds) + } + } + else -> { + appendSeconds(context, builder, seconds) + } + } + return builder.toString() + } + + private fun appendHours(context: Context, builder: StringBuilder, hours: Int) { + builder.append(hours) + builder.append(context.resources.getString(R.string.time_unit_hour_short)) + } + + private fun appendMinutes(context: Context, builder: StringBuilder, minutes: Int) { + builder.append(minutes) + builder.append(context.getString(R.string.time_unit_minute_short)) + } + + private fun appendSeconds(context: Context, builder: StringBuilder, seconds: Int) { + builder.append(seconds) + builder.append(context.getString(R.string.time_unit_second_short)) + } + + private fun getHours(duration: Duration): Int = duration.toHours().toInt() + private fun getMinutes(duration: Duration): Int = duration.toMinutes().toInt() % MINUTES_PER_HOUR + private fun getSeconds(duration: Duration): Int = (duration.seconds % SECONDS_PER_MINUTE).toInt() } diff --git a/vector/src/main/java/im/vector/app/core/utils/ToggleableAppBarLayoutBehavior.kt b/vector/src/main/java/im/vector/app/core/utils/ToggleableAppBarLayoutBehavior.kt new file mode 100644 index 0000000000..c829313256 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/ToggleableAppBarLayoutBehavior.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/badge/BadgeProxy.kt b/vector/src/main/java/im/vector/app/features/badge/BadgeProxy.kt deleted file mode 100644 index c15ec8bdf4..0000000000 --- a/vector/src/main/java/im/vector/app/features/badge/BadgeProxy.kt +++ /dev/null @@ -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.

    - * The refresh is only effective if the device is: - * * offline * does not support FCM - * * FCM registration failed - *

    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) - } - */ - } -} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationMessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationMessageItemFactory.kt deleted file mode 100644 index d233deffb8..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationMessageItemFactory.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.home.room.detail.timeline.factory - -import im.vector.app.core.epoxy.VectorEpoxyModel -import im.vector.app.core.utils.DimensionConverter -import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider -import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider -import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem -import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem -import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem_ -import org.matrix.android.sdk.api.extensions.orFalse -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import javax.inject.Inject - -class LiveLocationMessageItemFactory @Inject constructor( - private val dimensionConverter: DimensionConverter, - private val timelineMediaSizeProvider: TimelineMediaSizeProvider, - private val avatarSizeProvider: AvatarSizeProvider, -) { - - fun create( - beaconInfoContent: MessageBeaconInfoContent, - highlight: Boolean, - attributes: AbsMessageItem.Attributes, - ): VectorEpoxyModel<*>? { - // TODO handle location received and stopped states - return when { - isLiveRunning(beaconInfoContent) -> buildStartLiveItem(highlight, attributes) - else -> null - } - } - - private fun isLiveRunning(beaconInfoContent: MessageBeaconInfoContent): Boolean { - // TODO when we will use aggregatedSummary, check if the live has timed out as well - return beaconInfoContent.isLive.orFalse() - } - - private fun buildStartLiveItem( - highlight: Boolean, - attributes: AbsMessageItem.Attributes, - ): MessageLiveLocationStartItem { - val width = timelineMediaSizeProvider.getMaxSize().first - val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP) - - return MessageLiveLocationStartItem_() - .attributes(attributes) - .mapWidth(width) - .mapHeight(height) - .highlighted(highlight) - .leftGuideline(avatarSizeProvider.leftGuideline) - } -} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt new file mode 100644 index 0000000000..479a742369 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.factory + +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.resources.DateProvider +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider +import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider +import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider +import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem +import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationInactiveItem +import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationInactiveItem_ +import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationItem +import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationItem_ +import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem +import im.vector.app.features.home.room.detail.timeline.item.MessageLiveLocationStartItem_ +import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE +import im.vector.app.features.location.UrlMapProvider +import im.vector.app.features.location.toLocationData +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.threeten.bp.LocalDateTime +import timber.log.Timber +import javax.inject.Inject + +class LiveLocationShareMessageItemFactory @Inject constructor( + private val session: Session, + private val dimensionConverter: DimensionConverter, + private val timelineMediaSizeProvider: TimelineMediaSizeProvider, + private val avatarSizeProvider: AvatarSizeProvider, + private val urlMapProvider: UrlMapProvider, + private val locationPinProvider: LocationPinProvider, + private val vectorDateFormatter: VectorDateFormatter, +) { + + fun create( + event: TimelineEvent, + highlight: Boolean, + attributes: AbsMessageItem.Attributes, + ): VectorEpoxyModel<*>? { + val liveLocationShareSummaryData = getLiveLocationShareSummaryData(event) + val item = when (val currentState = getViewState(liveLocationShareSummaryData)) { + LiveLocationShareViewState.Inactive -> buildInactiveItem(highlight, attributes) + LiveLocationShareViewState.Loading -> buildLoadingItem(highlight, attributes) + is LiveLocationShareViewState.Running -> buildRunningItem(highlight, attributes, currentState) + LiveLocationShareViewState.Unkwown -> null + } + item?.layout(attributes.informationData.messageLayout.layoutRes) + + return item + } + + private fun buildInactiveItem( + highlight: Boolean, + attributes: AbsMessageItem.Attributes, + ): MessageLiveLocationInactiveItem { + val width = timelineMediaSizeProvider.getMaxSize().first + val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP) + + return MessageLiveLocationInactiveItem_() + .attributes(attributes) + .mapWidth(width) + .mapHeight(height) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + } + + private fun buildLoadingItem( + highlight: Boolean, + attributes: AbsMessageItem.Attributes, + ): MessageLiveLocationStartItem { + val width = timelineMediaSizeProvider.getMaxSize().first + val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP) + + return MessageLiveLocationStartItem_() + .attributes(attributes) + .mapWidth(width) + .mapHeight(height) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + } + + private fun buildRunningItem( + highlight: Boolean, + attributes: AbsMessageItem.Attributes, + runningState: LiveLocationShareViewState.Running, + ): MessageLiveLocationItem { + // TODO only render location if enabled in preferences: to be handled in a next PR + val width = timelineMediaSizeProvider.getMaxSize().first + val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP) + + val locationUrl = runningState.lastGeoUri.toLocationData()?.let { + urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height) + } + + return MessageLiveLocationItem_() + .attributes(attributes) + .locationUrl(locationUrl) + .mapWidth(width) + .mapHeight(height) + .locationUserId(attributes.informationData.senderId) + .locationPinProvider(locationPinProvider) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + .currentUserId(session.myUserId) + .endOfLiveDateTime(runningState.endOfLiveDateTime) + .vectorDateFormatter(vectorDateFormatter) + } + + private fun getViewState(liveLocationShareSummaryData: LiveLocationShareSummaryData?): LiveLocationShareViewState { + return when { + liveLocationShareSummaryData?.isActive == null -> LiveLocationShareViewState.Unkwown + liveLocationShareSummaryData.isActive.not() || isLiveTimedOut(liveLocationShareSummaryData) -> LiveLocationShareViewState.Inactive + liveLocationShareSummaryData.isActive && liveLocationShareSummaryData.lastGeoUri.isNullOrEmpty() -> LiveLocationShareViewState.Loading + else -> + LiveLocationShareViewState.Running( + liveLocationShareSummaryData.lastGeoUri.orEmpty(), + getEndOfLiveDateTime(liveLocationShareSummaryData) + ) + }.also { viewState -> Timber.d("computed viewState: $viewState") } + } + + private fun isLiveTimedOut(liveLocationShareSummaryData: LiveLocationShareSummaryData): Boolean { + return getEndOfLiveDateTime(liveLocationShareSummaryData) + ?.let { endOfLive -> + // this will only cover users with different timezones but not users with manually time set + val now = LocalDateTime.now() + now.isAfter(endOfLive) + } + .orFalse() + } + + private fun getEndOfLiveDateTime(liveLocationShareSummaryData: LiveLocationShareSummaryData): LocalDateTime? { + return liveLocationShareSummaryData.endOfLiveTimestampMillis?.let { DateProvider.toLocalDateTime(timestamp = it) } + } + + private fun getLiveLocationShareSummaryData(event: TimelineEvent): LiveLocationShareSummaryData? { + return event.annotations?.liveLocationShareAggregatedSummary?.let { summary -> + LiveLocationShareSummaryData( + isActive = summary.isActive, + endOfLiveTimestampMillis = summary.endOfLiveTimestampMillis, + lastGeoUri = summary.lastLocationDataContent?.getBestLocationInfo()?.geoUri + ) + } + } + + private data class LiveLocationShareSummaryData( + val isActive: Boolean?, + val endOfLiveTimestampMillis: Long?, + val lastGeoUri: String?, + ) + + private sealed class LiveLocationShareViewState { + object Loading : LiveLocationShareViewState() + data class Running(val lastGeoUri: String, val endOfLiveDateTime: LocalDateTime?) : LiveLocationShareViewState() + object Inactive : LiveLocationShareViewState() + object Unkwown : LiveLocationShareViewState() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index b960e2c6a9..13f783cded 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -148,7 +148,7 @@ class MessageItemFactory @Inject constructor( private val locationPinProvider: LocationPinProvider, private val vectorPreferences: VectorPreferences, private val urlMapProvider: UrlMapProvider, - private val liveLocationMessageItemFactory: LiveLocationMessageItemFactory, + private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory, ) { // TODO inject this properly? @@ -216,7 +216,7 @@ class MessageItemFactory @Inject constructor( buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) } } - is MessageBeaconInfoContent -> liveLocationMessageItemFactory.create(messageContent, highlight, attributes) + is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { @@ -237,14 +237,14 @@ class MessageItemFactory @Inject constructor( urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height) } - val userId = if (locationContent.isSelfLocation()) informationData.senderId else null + val locationUserId = if (locationContent.isSelfLocation()) informationData.senderId else null return MessageLocationItem_() .attributes(attributes) .locationUrl(locationUrl) .mapWidth(width) .mapHeight(height) - .userId(userId) + .locationUserId(locationUserId) .locationPinProvider(locationPinProvider) .highlighted(highlight) .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index f4bcc1ba65..07ae9d66c3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -100,7 +100,7 @@ class TimelineItemFactory @Inject constructor( // Message itemsX EventType.STICKER, in EventType.POLL_START, - EventType.MESSAGE -> messageItemFactory.create(params) + EventType.MESSAGE -> messageItemFactory.create(params) EventType.REDACTION, EventType.KEY_VERIFICATION_ACCEPT, EventType.KEY_VERIFICATION_START, @@ -113,14 +113,15 @@ class TimelineItemFactory @Inject constructor( EventType.CALL_NEGOTIATE, EventType.REACTION, in EventType.POLL_RESPONSE, - in EventType.POLL_END -> noticeItemFactory.create(params) + in EventType.POLL_END, + in EventType.BEACON_LOCATION_DATA -> noticeItemFactory.create(params) // Calls EventType.CALL_INVITE, EventType.CALL_HANGUP, EventType.CALL_REJECT, - EventType.CALL_ANSWER -> callItemFactory.create(params) + EventType.CALL_ANSWER -> callItemFactory.create(params) // Crypto - EventType.ENCRYPTED -> { + EventType.ENCRYPTED -> { if (event.root.isRedacted()) { // Redacted event, let the MessageItemFactory handle it messageItemFactory.create(params) @@ -129,11 +130,11 @@ class TimelineItemFactory @Inject constructor( } } EventType.KEY_VERIFICATION_CANCEL, - EventType.KEY_VERIFICATION_DONE -> { + EventType.KEY_VERIFICATION_DONE -> { verificationConclusionItemFactory.create(params) } // Unhandled event types - else -> { + else -> { // Should only happen when shouldShowHiddenEvents() settings is ON Timber.v("Type ${event.root.getClearType()} not handled") defaultItemFactory.create(params) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 7ad0cb27c6..8e06b3ee5d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -107,7 +107,8 @@ class NoticeEventFormatter @Inject constructor( EventType.REDACTION, EventType.STICKER, in EventType.POLL_RESPONSE, - in EventType.POLL_END -> formatDebug(timelineEvent.root) + in EventType.POLL_END, + in EventType.BEACON_LOCATION_DATA -> formatDebug(timelineEvent.root) else -> { Timber.v("Type $type not handled by this formatter") null diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 1e95f067d2..7874f843e1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -44,8 +44,7 @@ import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited import javax.inject.Inject /** - * This class compute if data of an event (such has avatar, display name, ...) should be displayed, depending on the previous event in the timeline. - * TODO Update this comment + * This class is responsible of building extra information data associated to a given event. */ class MessageInformationDataFactory @Inject constructor(private val session: Session, private val dateFormatter: VectorDateFormatter, @@ -119,7 +118,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses isFirstFromThisSender = isFirstFromThisSender, isLastFromThisSender = isLastFromThisSender, e2eDecoration = e2eDecoration, - sendStateDecoration = sendStateDecoration + sendStateDecoration = sendStateDecoration, ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt index 45c711ff93..737b0dc85d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -57,6 +57,7 @@ class MessageItemAttributesFactory @Inject constructor( memberClickListener = { callback?.onMemberNameClicked(informationData) }, + callback = callback, reactionPillCallback = callback, avatarCallback = callback, threadCallback = callback, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index 263f105b6b..b9d79d5818 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -178,6 +178,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem override val itemLongClickListener: View.OnLongClickListener? = null, override val itemClickListener: ClickListener? = null, val memberClickListener: ClickListener? = null, + val callback: TimelineEventController.Callback? = null, override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, val avatarCallback: TimelineEventController.AvatarCallback? = null, val threadCallback: TimelineEventController.ThreadCallback? = null, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageLocationItem.kt new file mode 100644 index 0000000000..f7146c24e9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageLocationItem.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.airbnb.epoxy.EpoxyAttribute +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.Target +import im.vector.app.R +import im.vector.app.core.glide.GlideApp +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners + +abstract class AbsMessageLocationItem : AbsMessageItem() { + + @EpoxyAttribute + var locationUrl: String? = null + + @EpoxyAttribute + var locationUserId: String? = null + + @EpoxyAttribute + var mapWidth: Int = 0 + + @EpoxyAttribute + var mapHeight: Int = 0 + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var locationPinProvider: LocationPinProvider? = null + + override fun bind(holder: H) { + super.bind(holder) + renderSendState(holder.view, null) + bindMap(holder) + } + + private fun bindMap(holder: Holder) { + val location = locationUrl ?: return + val messageLayout = attributes.informationData.messageLayout + val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) { + messageLayout.cornersRadius.granularRoundedCorners() + } else { + val dimensionConverter = DimensionConverter(holder.view.resources) + RoundedCorners(dimensionConverter.dpToPx(8)) + } + holder.staticMapImageView.updateLayoutParams { + width = mapWidth + height = mapHeight + } + GlideApp.with(holder.staticMapImageView) + .load(location) + .apply(RequestOptions.centerCropTransform()) + .listener(object : RequestListener { + override fun onLoadFailed(e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean): Boolean { + holder.staticMapPinImageView.setImageResource(R.drawable.ic_location_pin_failed) + holder.staticMapErrorTextView.isVisible = true + return false + } + + override fun onResourceReady(resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean): Boolean { + locationPinProvider?.create(locationUserId) { pinDrawable -> + // we are not using Glide since it does not display it correctly when there is no user photo + holder.staticMapPinImageView.setImageDrawable(pinDrawable) + } + holder.staticMapErrorTextView.isVisible = false + return false + } + }) + .transform(imageCornerTransformation) + .into(holder.staticMapImageView) + } + + abstract class Holder(@IdRes stubId: Int) : AbsMessageItem.Holder(stubId) { + val staticMapImageView by bind(R.id.staticMapImageView) + val staticMapPinImageView by bind(R.id.staticMapPinImageView) + val staticMapErrorTextView by bind(R.id.staticMapErrorTextView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt new file mode 100644 index 0000000000..c421efda12 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import android.content.res.Resources +import android.graphics.drawable.ColorDrawable +import android.widget.ImageView +import androidx.core.view.updateLayoutParams +import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import im.vector.app.R +import im.vector.app.core.glide.GlideApp +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners +import im.vector.app.features.themes.ThemeUtils + +/** + * Default implementation of common methods for item representing the status of a live location share. + */ +class DefaultLiveLocationShareStatusItem : LiveLocationShareStatusItem { + + override fun bindMap( + mapImageView: ImageView, + mapWidth: Int, + mapHeight: Int, + messageLayout: TimelineMessageLayout + ) { + val mapCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) { + messageLayout.cornersRadius.granularRoundedCorners() + } else { + RoundedCorners(getDefaultLayoutCornerRadiusInDp(mapImageView.resources)) + } + mapImageView.updateLayoutParams { + width = mapWidth + height = mapHeight + } + GlideApp.with(mapImageView) + .load(R.drawable.bg_no_location_map) + .transform(mapCornerTransformation) + .into(mapImageView) + } + + override fun bindBottomBanner(bannerImageView: ImageView, messageLayout: TimelineMessageLayout) { + val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) { + GranularRoundedCorners( + 0f, + 0f, + messageLayout.cornersRadius.bottomEndRadius, + messageLayout.cornersRadius.bottomStartRadius + ) + } else { + val bottomCornerRadius = getDefaultLayoutCornerRadiusInDp(bannerImageView.resources).toFloat() + GranularRoundedCorners(0f, 0f, bottomCornerRadius, bottomCornerRadius) + } + GlideApp.with(bannerImageView) + .load(ColorDrawable(ThemeUtils.getColor(bannerImageView.context, android.R.attr.colorBackground))) + .transform(imageCornerTransformation) + .into(bannerImageView) + } + + private fun getDefaultLayoutCornerRadiusInDp(resources: Resources): Int { + val dimensionConverter = DimensionConverter(resources) + return dimensionConverter.dpToPx(8) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/LiveLocationShareStatusItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/LiveLocationShareStatusItem.kt new file mode 100644 index 0000000000..2f79f2fc9e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/LiveLocationShareStatusItem.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import android.widget.ImageView +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout + +interface LiveLocationShareStatusItem { + fun bindMap( + mapImageView: ImageView, + mapWidth: Int, + mapHeight: Int, + messageLayout: TimelineMessageLayout + ) + + fun bindBottomBanner(bannerImageView: ImageView, messageLayout: TimelineMessageLayout) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt index 9620077fd8..258424c7de 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -42,7 +42,7 @@ data class MessageInformationData( val e2eDecoration: E2EDecoration = E2EDecoration.NONE, val sendStateDecoration: SendStateDecoration = SendStateDecoration.NONE, val isFirstFromThisSender: Boolean = false, - val isLastFromThisSender: Boolean = false + val isLastFromThisSender: Boolean = false, ) : Parcelable { val matrixItem: MatrixItem diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt new file mode 100644 index 0000000000..bb85316bf1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import android.widget.ImageView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base) +abstract class MessageLiveLocationInactiveItem : + AbsMessageItem(), + LiveLocationShareStatusItem by DefaultLiveLocationShareStatusItem() { + + @EpoxyAttribute + var mapWidth: Int = 0 + + @EpoxyAttribute + var mapHeight: Int = 0 + + override fun bind(holder: Holder) { + super.bind(holder) + renderSendState(holder.view, null) + bindMap(holder.noLocationMapImageView, mapWidth, mapHeight, attributes.informationData.messageLayout) + bindBottomBanner(holder.bannerImageView, attributes.informationData.messageLayout) + } + + override fun getViewStubId() = STUB_ID + + class Holder : AbsMessageItem.Holder(STUB_ID) { + val bannerImageView by bind(R.id.locationLiveInactiveBanner) + val noLocationMapImageView by bind(R.id.locationLiveInactiveMap) + } + + companion object { + private const val STUB_ID = R.id.messageContentLiveLocationInactiveStub + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt new file mode 100644 index 0000000000..838fbd46de --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.resources.toTimestamp +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.features.home.room.detail.RoomDetailAction +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.location.live.LocationLiveMessageBannerView +import im.vector.app.features.location.live.LocationLiveMessageBannerViewState +import org.threeten.bp.LocalDateTime + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base) +abstract class MessageLiveLocationItem : AbsMessageLocationItem() { + + @EpoxyAttribute + var currentUserId: String? = null + + @EpoxyAttribute + var endOfLiveDateTime: LocalDateTime? = null + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + lateinit var vectorDateFormatter: VectorDateFormatter + + override fun bind(holder: Holder) { + super.bind(holder) + bindLocationLiveBanner(holder) + } + + private fun bindLocationLiveBanner(holder: Holder) { + // TODO in a future PR add check on device id to confirm that is the one that sent the beacon + val isEmitter = currentUserId != null && currentUserId == locationUserId + val messageLayout = attributes.informationData.messageLayout + val viewState = buildViewState(holder, messageLayout, isEmitter) + holder.locationLiveMessageBanner.isVisible = true + holder.locationLiveMessageBanner.render(viewState) + holder.locationLiveMessageBanner.stopButton.setOnClickListener { + attributes.callback?.onTimelineItemAction(RoomDetailAction.StopLiveLocationSharing) + } + } + + private fun buildViewState( + holder: Holder, + messageLayout: TimelineMessageLayout, + isEmitter: Boolean + ): LocationLiveMessageBannerViewState { + return when { + messageLayout is TimelineMessageLayout.Bubble && isEmitter -> + LocationLiveMessageBannerViewState.Emitter( + remainingTimeInMillis = getRemainingTimeOfLiveInMillis(), + bottomStartCornerRadiusInDp = messageLayout.cornersRadius.bottomStartRadius, + bottomEndCornerRadiusInDp = messageLayout.cornersRadius.bottomEndRadius, + isStopButtonCenteredVertically = false + ) + messageLayout is TimelineMessageLayout.Bubble -> + LocationLiveMessageBannerViewState.Watcher( + bottomStartCornerRadiusInDp = messageLayout.cornersRadius.bottomStartRadius, + bottomEndCornerRadiusInDp = messageLayout.cornersRadius.bottomEndRadius, + formattedLocalTimeOfEndOfLive = getFormattedLocalTimeEndOfLive(), + ) + isEmitter -> { + val cornerRadius = getBannerCornerRadiusForDefaultLayout(holder) + LocationLiveMessageBannerViewState.Emitter( + remainingTimeInMillis = getRemainingTimeOfLiveInMillis(), + bottomStartCornerRadiusInDp = cornerRadius, + bottomEndCornerRadiusInDp = cornerRadius, + isStopButtonCenteredVertically = true + ) + } + else -> { + val cornerRadius = getBannerCornerRadiusForDefaultLayout(holder) + LocationLiveMessageBannerViewState.Watcher( + bottomStartCornerRadiusInDp = cornerRadius, + bottomEndCornerRadiusInDp = cornerRadius, + formattedLocalTimeOfEndOfLive = getFormattedLocalTimeEndOfLive(), + ) + } + } + } + + private fun getBannerCornerRadiusForDefaultLayout(holder: Holder): Float { + val dimensionConverter = DimensionConverter(holder.view.resources) + return dimensionConverter.dpToPx(8).toFloat() + } + + private fun getFormattedLocalTimeEndOfLive() = + endOfLiveDateTime?.toTimestamp()?.let { vectorDateFormatter.format(it, DateFormatKind.MESSAGE_SIMPLE) }.orEmpty() + + private fun getRemainingTimeOfLiveInMillis() = + (endOfLiveDateTime?.toTimestamp() ?: 0) - LocalDateTime.now().toTimestamp() + + override fun getViewStubId() = STUB_ID + + class Holder : AbsMessageLocationItem.Holder(STUB_ID) { + val locationLiveMessageBanner by bind(R.id.locationLiveMessageBanner) + } + + companion object { + private const val STUB_ID = R.id.messageContentLiveLocationStub + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt index 390db0ef50..001774b579 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationStartItem.kt @@ -16,22 +16,15 @@ package im.vector.app.features.home.room.detail.timeline.item -import android.graphics.drawable.ColorDrawable import android.widget.ImageView -import androidx.core.view.updateLayoutParams import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass -import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners -import com.bumptech.glide.load.resource.bitmap.RoundedCorners import im.vector.app.R -import im.vector.app.core.glide.GlideApp -import im.vector.app.core.utils.DimensionConverter -import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout -import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners -import im.vector.app.features.themes.ThemeUtils @EpoxyModelClass(layout = R.layout.item_timeline_event_base) -abstract class MessageLiveLocationStartItem : AbsMessageItem() { +abstract class MessageLiveLocationStartItem : + AbsMessageItem(), + LiveLocationShareStatusItem by DefaultLiveLocationShareStatusItem() { @EpoxyAttribute var mapWidth: Int = 0 @@ -42,44 +35,8 @@ abstract class MessageLiveLocationStartItem : AbsMessageItem() { - - @EpoxyAttribute - var locationUrl: String? = null - - @EpoxyAttribute - var userId: String? = null - - @EpoxyAttribute - var mapWidth: Int = 0 - - @EpoxyAttribute - var mapHeight: Int = 0 - - @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) - var locationPinProvider: LocationPinProvider? = null - - override fun bind(holder: Holder) { - super.bind(holder) - renderSendState(holder.view, null) - val location = locationUrl ?: return - val messageLayout = attributes.informationData.messageLayout - val dimensionConverter = DimensionConverter(holder.view.resources) - val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) { - messageLayout.cornersRadius.granularRoundedCorners() - } else { - RoundedCorners(dimensionConverter.dpToPx(8)) - } - holder.staticMapImageView.updateLayoutParams { - width = mapWidth - height = mapHeight - } - GlideApp.with(holder.staticMapImageView) - .load(location) - .apply(RequestOptions.centerCropTransform()) - .listener(object : RequestListener { - override fun onLoadFailed(e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean): Boolean { - holder.staticMapPinImageView.setImageResource(R.drawable.ic_location_pin_failed) - holder.staticMapErrorTextView.isVisible = true - return false - } - - override fun onResourceReady(resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean): Boolean { - locationPinProvider?.create(userId) { pinDrawable -> - GlideApp.with(holder.staticMapPinImageView) - .load(pinDrawable) - .into(holder.staticMapPinImageView) - } - holder.staticMapErrorTextView.isVisible = false - return false - } - }) - .transform(imageCornerTransformation) - .into(holder.staticMapImageView) - } +abstract class MessageLocationItem : AbsMessageLocationItem() { override fun getViewStubId() = STUB_ID - class Holder : AbsMessageItem.Holder(STUB_ID) { - val staticMapImageView by bind(R.id.staticMapImageView) - val staticMapPinImageView by bind(R.id.staticMapPinImageView) - val staticMapErrorTextView by bind(R.id.staticMapErrorTextView) - } + class Holder : AbsMessageLocationItem.Holder(STUB_ID) companion object { private const val STUB_ID = R.id.messageContentLocationStub diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt index cb0b2384ec..a0d10a8a75 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt @@ -66,6 +66,11 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess MessageType.MSGTYPE_VIDEO, MessageType.MSGTYPE_BEACON_INFO, ) + + private val MSG_TYPES_WITH_LOCATION_DATA = setOf( + MessageType.MSGTYPE_LOCATION, + MessageType.MSGTYPE_BEACON_LOCATION_DATA + ) } private val cornerRadius: Float by lazy { @@ -145,9 +150,11 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess } private fun MessageContent?.timestampInsideMessage(): Boolean { - if (this == null) return false - if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline() - return this.msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE + return when { + this == null -> false + msgType in MSG_TYPES_WITH_LOCATION_DATA -> vectorPreferences.labsRenderLocationsInTimeline() + else -> msgType in MSG_TYPES_WITH_TIMESTAMP_INSIDE_MESSAGE + } } private fun MessageContent?.shouldAddMessageOverlay(): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt new file mode 100644 index 0000000000..1ae017c98c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt @@ -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() { + class Holder : VectorEpoxyHolder() +} diff --git a/vector/src/main/java/im/vector/app/features/location/Config.kt b/vector/src/main/java/im/vector/app/features/location/Config.kt index 6f947290e2..c29e2e911a 100644 --- a/vector/src/main/java/im/vector/app/features/location/Config.kt +++ b/vector/src/main/java/im/vector/app/features/location/Config.kt @@ -22,5 +22,5 @@ const val DEFAULT_PIN_ID = "DEFAULT_PIN_ID" const val INITIAL_MAP_ZOOM_IN_PREVIEW = 15.0 const val INITIAL_MAP_ZOOM_IN_TIMELINE = 17.0 -const val MIN_TIME_TO_UPDATE_LOCATION_MILLIS = 5 * 1_000L // every 5 seconds +const val MIN_TIME_TO_UPDATE_LOCATION_MILLIS = 2 * 1_000L // every 2 seconds const val MIN_DISTANCE_TO_UPDATE_LOCATION_METERS = 10f diff --git a/vector/src/main/java/im/vector/app/features/location/LocationData.kt b/vector/src/main/java/im/vector/app/features/location/LocationData.kt index 061f338e72..b3466ff871 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationData.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationData.kt @@ -29,7 +29,7 @@ data class LocationData( ) : Parcelable /** - * Creates location data from a LocationContent. + * Creates location data from a MessageLocationContent. * "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30) * @return location data or null if geo uri is not valid */ @@ -37,6 +37,15 @@ fun MessageLocationContent.toLocationData(): LocationData? { return parseGeo(getBestGeoUri()) } +/** + * Creates location data from a geoUri String. + * "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30) + * @return location data or null if geo uri is null or not valid + */ +fun String?.toLocationData(): LocationData? { + return this?.let { parseGeo(it) } +} + @VisibleForTesting fun parseGeo(geo: String): LocationData? { val geoParts = geo diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt index 362b82ccf5..8b9a1c75ae 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt @@ -55,7 +55,10 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { private val binder = LocalBinder() - private var roomArgsList = mutableListOf() + /** + * Keep track of a map between beacon event Id starting the live and RoomArgs. + */ + private var roomArgsMap = mutableMapOf() private var timers = mutableListOf() override fun onCreate() { @@ -73,8 +76,6 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { Timber.i("### LocationSharingService.onStartCommand. sessionId - roomId ${roomArgs?.sessionId} - ${roomArgs?.roomId}") if (roomArgs != null) { - roomArgsList.add(roomArgs) - // Show a sticky notification val notification = notificationUtils.buildLiveLocationSharingNotification() startForeground(roomArgs.roomId.hashCode(), notification) @@ -87,7 +88,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { .getSafeActiveSession() ?.let { session -> session.coroutineScope.launch(session.coroutineDispatchers.io) { - sendLiveBeaconInfo(session, roomArgs) + sendStartingLiveBeaconInfo(session, roomArgs) } } } @@ -95,7 +96,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { return START_STICKY } - private suspend fun sendLiveBeaconInfo(session: Session, roomArgs: RoomArgs) { + private suspend fun sendStartingLiveBeaconInfo(session: Session, roomArgs: RoomArgs) { val beaconContent = MessageBeaconInfoContent( timeout = roomArgs.durationMillis, isLive = true, @@ -103,7 +104,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { ).toContent() val stateKey = session.myUserId - session + val beaconEventId = session .getRoom(roomArgs.roomId) ?.stateService() ?.sendStateEvent( @@ -111,6 +112,16 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { stateKey = stateKey, body = beaconContent ) + + beaconEventId + ?.takeUnless { it.isEmpty() } + ?.let { + roomArgsMap[it] = roomArgs + locationTracker.requestLastKnownLocation() + } + ?: run { + Timber.w("### LocationSharingService.sendStartingLiveBeaconInfo error, no received beacon info id") + } } private fun scheduleTimer(roomId: String, durationMillis: Long) { @@ -134,9 +145,13 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { // Send a new beacon info state by setting live field as false sendStoppedBeaconInfo(roomId) - synchronized(roomArgsList) { - roomArgsList.removeAll { it.roomId == roomId } - if (roomArgsList.isEmpty()) { + synchronized(roomArgsMap) { + val beaconIds = roomArgsMap + .filter { it.value.roomId == roomId } + .map { it.key } + beaconIds.forEach { roomArgsMap.remove(it) } + + if (roomArgsMap.isEmpty()) { Timber.i("### LocationSharingService. Destroying self, time is up for all rooms") destroyMe() } @@ -156,16 +171,17 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { override fun onLocationUpdate(locationData: LocationData) { Timber.i("### LocationSharingService.onLocationUpdate. Uncertainty: ${locationData.uncertainty}") - val session = activeSessionHolder.getSafeActiveSession() // Emit location update to all rooms in which live location sharing is active - session?.coroutineScope?.launch(session.coroutineDispatchers.io) { - roomArgsList.toList().forEach { roomArg -> - sendLiveLocation(roomArg.roomId, locationData) - } + roomArgsMap.toMap().forEach { item -> + sendLiveLocation(item.value.roomId, item.key, locationData) } } - private suspend fun sendLiveLocation(roomId: String, locationData: LocationData) { + private fun sendLiveLocation( + roomId: String, + beaconInfoEventId: String, + locationData: LocationData + ) { val session = activeSessionHolder.getSafeActiveSession() val room = session?.getRoom(roomId) val userId = session?.myUserId @@ -174,18 +190,12 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { return } - room - .stateService() - .getLiveLocationBeaconInfo(userId, true) - ?.eventId - ?.let { - room.sendService().sendLiveLocation( - beaconInfoEventId = it, - latitude = locationData.latitude, - longitude = locationData.longitude, - uncertainty = locationData.uncertainty - ) - } + room.sendService().sendLiveLocation( + beaconInfoEventId = beaconInfoEventId, + latitude = locationData.latitude, + longitude = locationData.longitude, + uncertainty = locationData.uncertainty + ) } override fun onLocationProviderIsNotAvailable() { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt index b7006370a6..4e56e7954c 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt @@ -40,10 +40,12 @@ class LocationTracker @Inject constructor( fun onLocationProviderIsNotAvailable() } - private var callbacks = mutableListOf() + private val callbacks = mutableListOf() private var hasGpsProviderLiveLocation = false + private var lastLocation: LocationData? = null + @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) fun start() { Timber.d("## LocationTracker. start()") @@ -92,6 +94,14 @@ class LocationTracker @Inject constructor( callbacks.clear() } + /** + * Request the last known location. It will be given async through Callback. + * Please ensure adding a callback to receive the value. + */ + fun requestLastKnownLocation() { + lastLocation?.let { location -> callbacks.forEach { it.onLocationUpdate(location) } } + } + fun addCallback(callback: Callback) { if (!callbacks.contains(callback)) { callbacks.add(callback) @@ -127,7 +137,9 @@ class LocationTracker @Inject constructor( } } } - callbacks.forEach { it.onLocationUpdate(location.toLocationData()) } + val locationData = location.toLocationData() + lastLocation = locationData + callbacks.forEach { it.onLocationUpdate(locationData) } } override fun onProviderDisabled(provider: String) { diff --git a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt new file mode 100644 index 0000000000..8cb552e3c4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.location.live + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.os.CountDownTimer +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.view.isVisible +import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners +import im.vector.app.R +import im.vector.app.core.glide.GlideApp +import im.vector.app.core.utils.TextUtils +import im.vector.app.databinding.ViewLocationLiveMessageBannerBinding +import im.vector.app.features.themes.ThemeUtils +import org.threeten.bp.Duration + +private const val REMAINING_TIME_COUNTER_INTERVAL_IN_MS = 1000L + +class LocationLiveMessageBannerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding = ViewLocationLiveMessageBannerBinding.inflate( + LayoutInflater.from(context), + this + ) + + val stopButton: Button + get() = binding.locationLiveMessageBannerStop + + private val background: ImageView + get() = binding.locationLiveMessageBannerBackground + + private val title: TextView + get() = binding.locationLiveMessageBannerTitle + + private val subTitle: TextView + get() = binding.locationLiveMessageBannerSubTitle + + private var countDownTimer: CountDownTimer? = null + + fun render(viewState: LocationLiveMessageBannerViewState) { + when (viewState) { + is LocationLiveMessageBannerViewState.Emitter -> renderEmitter(viewState) + is LocationLiveMessageBannerViewState.Watcher -> renderWatcher(viewState) + } + + GlideApp.with(context) + .load(ColorDrawable(ThemeUtils.getColor(context, android.R.attr.colorBackground))) + .transform(GranularRoundedCorners(0f, 0f, viewState.bottomEndCornerRadiusInDp, viewState.bottomStartCornerRadiusInDp)) + .into(background) + } + + private fun renderEmitter(viewState: LocationLiveMessageBannerViewState.Emitter) { + stopButton.isVisible = true + title.text = context.getString(R.string.location_share_live_enabled) + + countDownTimer?.cancel() + viewState.remainingTimeInMillis + .takeIf { it >= 0 } + ?.let { + countDownTimer = object : CountDownTimer(it, REMAINING_TIME_COUNTER_INTERVAL_IN_MS) { + override fun onTick(millisUntilFinished: Long) { + val duration = Duration.ofMillis(millisUntilFinished.coerceAtLeast(0L)) + subTitle.text = context.getString( + R.string.location_share_live_remaining_time, + TextUtils.formatDurationWithUnits(context, duration) + ) + } + + override fun onFinish() { + subTitle.text = context.getString( + R.string.location_share_live_remaining_time, + TextUtils.formatDurationWithUnits(context, Duration.ofMillis(0L)) + ) + } + } + countDownTimer?.start() + } + + val rootLayout: ConstraintLayout? = (binding.root as? ConstraintLayout) + rootLayout?.let { parentLayout -> + val constraintSet = ConstraintSet() + constraintSet.clone(rootLayout) + + if (viewState.isStopButtonCenteredVertically) { + constraintSet.connect( + R.id.locationLiveMessageBannerStop, + ConstraintSet.BOTTOM, + R.id.locationLiveMessageBannerBackground, + ConstraintSet.BOTTOM, + 0 + ) + } else { + constraintSet.clear(R.id.locationLiveMessageBannerStop, ConstraintSet.BOTTOM) + } + + constraintSet.applyTo(parentLayout) + } + } + + private fun renderWatcher(viewState: LocationLiveMessageBannerViewState.Watcher) { + stopButton.isVisible = false + title.text = context.getString(R.string.location_share_live_view) + subTitle.text = context.getString(R.string.location_share_live_until, viewState.formattedLocalTimeOfEndOfLive) + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerViewState.kt b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerViewState.kt new file mode 100644 index 0000000000..976085386b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerViewState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.location.live + +sealed class LocationLiveMessageBannerViewState( + open val bottomStartCornerRadiusInDp: Float, + open val bottomEndCornerRadiusInDp: Float, +) { + + data class Emitter( + override val bottomStartCornerRadiusInDp: Float, + override val bottomEndCornerRadiusInDp: Float, + val remainingTimeInMillis: Long, + val isStopButtonCenteredVertically: Boolean + ) : LocationLiveMessageBannerViewState(bottomStartCornerRadiusInDp, bottomEndCornerRadiusInDp) + + data class Watcher( + override val bottomStartCornerRadiusInDp: Float, + override val bottomEndCornerRadiusInDp: Float, + val formattedLocalTimeOfEndOfLive: String, + ) : LocationLiveMessageBannerViewState(bottomStartCornerRadiusInDp, bottomEndCornerRadiusInDp) +} diff --git a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt index c9cb4612a8..1b08d2a86f 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt @@ -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) } ) diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt index e72e3a1790..6b9d255937 100644 --- a/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt @@ -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, diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 7cc42ec57f..0f921ab80a 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt b/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt index 171d8f7bb5..3014b199b4 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt @@ -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) } ) diff --git a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt index 4dd5a68673..91269cb114 100644 --- a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt +++ b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt @@ -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) } } diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomViewModel.kt index 1994de396f..f1306d9851 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomViewModel.kt @@ -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() ) } } diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryListCreator.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryListCreator.kt index 90283de77c..a168ea749c 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryListCreator.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryListCreator.kt @@ -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() // Add user homeserver name - val userHsName = session.myUserId.getDomain() + val userHsName = session.myUserId.getServerName() // Add default protocol protocols.add( diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/alias/RoomAliasViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/alias/RoomAliasViewModel.kt index 2641eb4184..a66ef9a659 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/alias/RoomAliasViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/alias/RoomAliasViewModel.kt @@ -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() ) } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/LeaveSpaceBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/LeaveSpaceBottomSheet.kt deleted file mode 100644 index a292b64ddd..0000000000 --- a/vector/src/main/java/im/vector/app/features/spaces/LeaveSpaceBottomSheet.kt +++ /dev/null @@ -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() { - - 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)) - } - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt index 78eab5b97f..94aa7e19b8 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt @@ -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() { 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,43 +136,52 @@ class SpaceDirectoryController @Inject constructor( } } } else { - flattenChildInfo.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 - val error = (data?.changeMembershipStates?.get(info.childRoomId) as? ChangeMembershipState.FailedJoining)?.throwable - // if it's known use that matrixItem because it would have a better computed name - val matrixItem = data?.knownRoomSummaries?.find { it.roomId == info.childRoomId }?.toMatrixItem() - ?: info.toMatrixItem() + matchFilter.filter = data?.currentFilter ?: "" + val filteredChildInfo = flattenChildInfo.filter { matchFilter.test(it) } - spaceChildInfoItem { - id(info.childRoomId) - matrixItem(matrixItem) - avatarRenderer(host.avatarRenderer) - topic(info.topic) - suggested(info.suggested.orFalse()) - errorLabel( - error?.let { - host.stringProvider.getString(R.string.error_failed_to_join_room, host.errorFormatter.toHumanReadable(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 + val error = (data?.changeMembershipStates?.get(info.childRoomId) as? ChangeMembershipState.FailedJoining)?.throwable + // if it's known use that matrixItem because it would have a better computed name + val matrixItem = data?.knownRoomSummaries?.find { it.roomId == info.childRoomId }?.toMatrixItem() + ?: info.toMatrixItem() + + spaceChildInfoItem { + id(info.childRoomId) + matrixItem(matrixItem) + avatarRenderer(host.avatarRenderer) + topic(info.topic) + suggested(info.suggested.orFalse()) + errorLabel( + error?.let { + host.stringProvider.getString(R.string.error_failed_to_join_room, host.errorFormatter.toHumanReadable(it)) + } + ) + memberCount(info.activeMemberCount ?: 0) + loading(isLoading) + buttonLabel( + when { + error != null -> host.stringProvider.getString(R.string.global_retry) + isJoined -> host.stringProvider.getString(R.string.action_open) + else -> host.stringProvider.getString(R.string.action_join) + } + ) + apply { + if (isSpace) { + itemClickListener { host.listener?.onSpaceChildClick(info) } + } else { + itemClickListener { host.listener?.onRoomClick(info) } } - ) - memberCount(info.activeMemberCount ?: 0) - loading(isLoading) - buttonLabel( - when { - error != null -> host.stringProvider.getString(R.string.global_retry) - isJoined -> host.stringProvider.getString(R.string.action_open) - else -> host.stringProvider.getString(R.string.action_join) - } - ) - apply { - if (isSpace) { - itemClickListener { host.listener?.onSpaceChildClick(info) } - } else { - itemClickListener { host.listener?.onRoomClick(info) } } + buttonClickListener { host.listener?.onButtonClick(info) } } - buttonClickListener { host.listener?.onButtonClick(info) } } } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt index e59087778f..ed0bbdd911 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt @@ -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(), 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)) } diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt index 2166a7e306..1d180eea4f 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt index 2ddcb42e2a..7ae2feebcf 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt @@ -225,9 +225,16 @@ 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()) { _viewEvents.post(SpaceDirectoryViewEvents.Dismiss) diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt index 68b313ec7f..a25476bff9 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt index b8dcd3f7a2..fce5f4efa1 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt @@ -28,8 +28,11 @@ data class SpaceLeaveAdvanceViewState( val allChildren: Async> = Uninitialized, val selectedRooms: List = emptyList(), val currentFilter: String = "", - val leaveState: Async = Uninitialized + val leaveState: Async = Uninitialized, + val isFilteringEnabled: Boolean = false, + val isLastAdmin: Boolean = false ) : MavericksState { + constructor(args: SpaceBottomSheetSettingsArgs) : this( spaceId = args.spaceId ) diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt index 53c7481acb..308572a30f 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt @@ -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())) - } - .launchIn(viewLifecycleOwner.lifecycleScope) + views.spaceLeaveSelectGroup.setOnCheckedChangeListener { _, optionId -> + when (optionId) { + R.id.spaceLeaveSelectAll -> viewModel.handle(SpaceLeaveAdvanceViewAction.SelectAll) + R.id.spaceLeaveSelectNone -> viewModel.handle(SpaceLeaveAdvanceViewAction.SelectNone) + } + } + } + + 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 + } + }) + } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt index 3f5a27f696..2ab417ac55 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt @@ -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(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") - } - } + init { + val space = session.getRoom(initialState.spaceId) + val spaceSummary = space?.roomSummary() - 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) } + val powerLevelsEvent = space?.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS) + powerLevelsEvent?.content?.toModel()?.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) } } - } - init { - val spaceSummary = session.getRoomSummary(initialState.spaceId) 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 { override fun create(initialState: SpaceLeaveAdvanceViewState): SpaceLeaveAdvancedViewModel diff --git a/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp index 23a45700f0..3241b5dc82 100644 Binary files a/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-hdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp index a6130fba78..03f9ba5062 100644 Binary files a/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-mdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-night-hdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-hdpi/bg_no_location_map.webp new file mode 100644 index 0000000000..76e0a75dd6 Binary files /dev/null and b/vector/src/main/res/drawable-night-hdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-night-mdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-mdpi/bg_no_location_map.webp new file mode 100644 index 0000000000..79900cec1b Binary files /dev/null and b/vector/src/main/res/drawable-night-mdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-night-xhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-xhdpi/bg_no_location_map.webp new file mode 100644 index 0000000000..14f7e0e44c Binary files /dev/null and b/vector/src/main/res/drawable-night-xhdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-night-xxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-xxhdpi/bg_no_location_map.webp new file mode 100644 index 0000000000..91cb7c8eb6 Binary files /dev/null and b/vector/src/main/res/drawable-night-xxhdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-night-xxxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-night-xxxhdpi/bg_no_location_map.webp new file mode 100644 index 0000000000..e4864a9eb2 Binary files /dev/null and b/vector/src/main/res/drawable-night-xxxhdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp index e908191371..513089b55b 100644 Binary files a/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-xhdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp index e062178367..50284965a7 100644 Binary files a/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-xxhdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp b/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp index 8b110d33fe..881af0055a 100644 Binary files a/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp and b/vector/src/main/res/drawable-xxxhdpi/bg_no_location_map.webp differ diff --git a/vector/src/main/res/layout/bottom_sheet_leave_space.xml b/vector/src/main/res/layout/bottom_sheet_leave_space.xml deleted file mode 100644 index 9e5a7c7ebf..0000000000 --- a/vector/src/main/res/layout/bottom_sheet_leave_space.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -