diff --git a/dependencies.gradle b/dependencies.gradle index 87b8e3c12ffabfac2fa80507b8587ef76d1cb181..7666a3bf9f8b4cc9929a65f2b0822eaae753cb33 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -9,13 +9,13 @@ ext.versions = [ def gradle = "7.0.4" // Ref: https://kotlinlang.org/releases.html -def kotlin = "1.5.31" -def kotlinCoroutines = "1.5.2" +def kotlin = "1.6.0" +def kotlinCoroutines = "1.6.0" def dagger = "2.40.5" def retrofit = "2.9.0" def arrow = "0.8.2" def markwon = "4.6.2" -def moshi = "1.12.0" +def moshi = "1.13.0" def lifecycle = "2.4.0" def flowBinding = "1.2.0" def epoxy = "4.6.2" @@ -58,6 +58,7 @@ ext.libs = [ 'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle", 'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle", 'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle", + 'lifecycleRuntimeKtx' : "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle", 'datastore' : "androidx.datastore:datastore:1.0.0", 'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0", 'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2", @@ -141,4 +142,4 @@ ext.libs = [ 'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1", 'junit' : "junit:junit:4.13.2" ] -] \ No newline at end of file +] diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index 45883f506d4326389bd26e24fb69856ea533e605..9e70a1de421068dc5f1e8989cea91d7f4a69558c 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -7,6 +7,7 @@ ext.groups = [ 'com.github.chrisbanes', 'com.github.hyuwah', 'com.github.jetradarmobile', + 'com.github.MatrixFrog', 'com.github.tapadoo', 'com.github.vector-im', 'com.github.yalantis', @@ -39,6 +40,7 @@ ext.groups = [ regex: [ ], group: [ + 'ch.qos.logback', 'com.adevinta.android', 'com.airbnb.android', 'com.almworks.sqlite4java', @@ -48,10 +50,12 @@ ext.groups = [ 'com.beust', 'com.davemorrissey.labs', 'com.dropbox.core', + 'com.facebook.fbjni', 'com.facebook.fresco', 'com.facebook.infer.annotation', 'com.facebook.soloader', 'com.facebook.stetho', + 'com.facebook.yoga', 'com.fasterxml', 'com.fasterxml.jackson', 'com.fasterxml.jackson.core', @@ -113,6 +117,7 @@ ext.groups = [ 'info.picocli', 'io.arrow-kt', 'io.github.detekt.sarif4k', + 'io.github.microutils', 'io.github.reactivecircus.flowbinding', 'io.grpc', 'io.jsonwebtoken', diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 6605acd29384c4104fd1e77cddbff46d6aafa9c6..ec4de9615ab3aa11d8105a0fd06eb5e2600182d5 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -37,9 +37,8 @@ android { buildConfigField "String", "SDK_VERSION", "\"${project.getProperties().getOrDefault("VERSION_NAME", "0.0.0")}\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" - resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" - resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\"" - resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\"" + buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" + buildConfigField "String", "GIT_SDK_REVISION_DATE", "\"${gitRevisionDate()}\"" defaultConfig { consumerProguardFiles 'proguard-rules.pro' @@ -170,7 +169,7 @@ dependencies { implementation libs.apache.commonsImaging // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.44' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.46' testImplementation libs.tests.junit testImplementation 'org.robolectric:robolectric:4.7.3' @@ -178,7 +177,7 @@ dependencies { // Note: version sticks to 1.9.2 due to https://github.com/mockk/mockk/issues/281 testImplementation libs.mockk.mockk testImplementation libs.tests.kluent - implementation libs.jetbrains.coroutinesAndroid + testImplementation libs.jetbrains.coroutinesTest // Plant Timber tree for test testImplementation 'net.lachlanmckee:timber-junit-rule:1.0.1' // Transitively required for mocking realm as monarchy doesn't expose Rx diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt index 5c9b79361e891d545fbaaea1774a68f8c6f75145..0f79896b2cd28a6f42bdc7fd19ded9a3a15c1dbf 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt @@ -23,7 +23,7 @@ object TestConstants { const val TESTS_HOME_SERVER_URL = "http://10.0.2.2:8080" // Time out to use when waiting for server response. - private const val AWAIT_TIME_OUT_MILLIS = 30_000 + private const val AWAIT_TIME_OUT_MILLIS = 60_000 // Time out to use when waiting for server response, when the debugger is connected. 10 minutes private const val AWAIT_TIME_OUT_WITH_DEBUGGER_MILLIS = 10 * 60_000 diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt new file mode 100644 index 0000000000000000000000000000000000000000..41ec69cdc5cae520e6ce371ad63a3b932a424dac --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt @@ -0,0 +1,649 @@ +/* + * 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.crypto + +import android.util.Log +import androidx.test.filters.LargeTest +import kotlinx.coroutines.delay +import org.amshove.kluent.fail +import org.amshove.kluent.internal.assertEquals +import org.junit.Assert +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.crypto.MXCryptoError +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.room.Room +import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +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 org.matrix.android.sdk.common.TestMatrixCallback +import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult +import org.matrix.android.sdk.internal.crypto.keysbackup.model.MegolmBackupCreationInfo +import org.matrix.android.sdk.internal.crypto.keysbackup.model.rest.KeysVersion +import org.matrix.android.sdk.internal.crypto.keysbackup.model.rest.KeysVersionResult +import org.matrix.android.sdk.internal.crypto.model.ImportRoomKeysResult +import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +@LargeTest +class E2eeSanityTests : InstrumentedTest { + + private val testHelper = CommonTestHelper(context()) + private val cryptoTestHelper = CryptoTestHelper(testHelper) + + /** + * Simple test that create an e2ee room. + * Some new members are added, and a message is sent. + * We check that the message is e2e and can be decrypted. + * + * Additional users join, we check that they can't decrypt history + * + * Alice sends a new message, then check that the new one can be decrypted + */ + @Test + fun testSendingE2EEMessages() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + val aliceSession = cryptoTestData.firstSession + val e2eRoomID = cryptoTestData.roomId + + val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!! + + // add some more users and invite them + val otherAccounts = listOf("benoit", "valere", "ganfra") // , "adam", "manu") + .map { + testHelper.createAccount(it, SessionTestParams(true)) + } + + Log.v("#E2E TEST", "All accounts created") + // we want to invite them in the room + otherAccounts.forEach { + testHelper.runBlockingTest { + Log.v("#E2E TEST", "Alice invites ${it.myUserId}") + aliceRoomPOV.invite(it.myUserId) + } + } + + // All user should accept invite + otherAccounts.forEach { otherSession -> + waitForAndAcceptInviteInRoom(otherSession, e2eRoomID) + Log.v("#E2E TEST", "${otherSession.myUserId} joined room $e2eRoomID") + } + + // check that alice see them as joined (not really necessary?) + ensureMembersHaveJoined(aliceSession, otherAccounts, e2eRoomID) + + Log.v("#E2E TEST", "All users have joined the room") + Log.v("#E2E TEST", "Alice is sending the message") + + val text = "This is my message" + val sentEventId: String? = sendMessageInRoom(aliceRoomPOV, text) + // val sentEvent = testHelper.sendTextMessage(aliceRoomPOV, "Hello all", 1).first() + Assert.assertTrue("Message should be sent", sentEventId != null) + + // All should be able to decrypt + otherAccounts.forEach { otherSession -> + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = otherSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId!!) + timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE + } + } + } + + // Add a new user to the room, and check that he can't decrypt + val newAccount = listOf("adam") // , "adam", "manu") + .map { + testHelper.createAccount(it, SessionTestParams(true)) + } + + newAccount.forEach { + testHelper.runBlockingTest { + Log.v("#E2E TEST", "Alice invites ${it.myUserId}") + aliceRoomPOV.invite(it.myUserId) + } + } + + newAccount.forEach { + waitForAndAcceptInviteInRoom(it, e2eRoomID) + } + + ensureMembersHaveJoined(aliceSession, newAccount, e2eRoomID) + + // wait a bit + testHelper.runBlockingTest { + delay(3_000) + } + + // check that messages are encrypted (uisi) + newAccount.forEach { otherSession -> + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = otherSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId!!).also { + Log.v("#E2E TEST", "Event seen by new user ${it?.root?.getClearType()}|${it?.root?.mCryptoError}") + } + timelineEvent != null && + timelineEvent.root.getClearType() == EventType.ENCRYPTED && + timelineEvent.root.mCryptoError == MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID + } + } + } + + // Let alice send a new message + Log.v("#E2E TEST", "Alice sends a new message") + + val secondMessage = "2 This is my message" + val secondSentEventId: String? = sendMessageInRoom(aliceRoomPOV, secondMessage) + + // new members should be able to decrypt it + newAccount.forEach { otherSession -> + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = otherSession.getRoom(e2eRoomID)?.getTimelineEvent(secondSentEventId!!).also { + Log.v("#E2E TEST", "Second Event seen by new user ${it?.root?.getClearType()}|${it?.root?.mCryptoError}") + } + timelineEvent != null && + timelineEvent.root.getClearType() == EventType.MESSAGE && + secondMessage == timelineEvent.root.getClearContent().toModel<MessageContent>()?.body + } + } + } + + otherAccounts.forEach { + testHelper.signOutAndClose(it) + } + newAccount.forEach { testHelper.signOutAndClose(it) } + + cryptoTestData.cleanUp(testHelper) + } + + /** + * Quick test for basic key backup + * 1. Create e2e between Alice and Bob + * 2. Alice sends 3 messages, using 3 different sessions + * 3. Ensure bob can decrypt + * 4. Create backup for bob and upload keys + * + * 5. Sign out alice and bob to ensure no gossiping will happen + * + * 6. Let bob sign in with a new session + * 7. Ensure history is UISI + * 8. Import backup + * 9. Check that new session can decrypt + */ + @Test + fun testBasicBackupImport() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + val aliceSession = cryptoTestData.firstSession + val bobSession = cryptoTestData.secondSession!! + val e2eRoomID = cryptoTestData.roomId + + Log.v("#E2E TEST", "Create and start key backup for bob ...") + val bobKeysBackupService = bobSession.cryptoService().keysBackupService() + val keyBackupPassword = "FooBarBaz" + val megolmBackupCreationInfo = testHelper.doSync<MegolmBackupCreationInfo> { + bobKeysBackupService.prepareKeysBackupVersion(keyBackupPassword, null, it) + } + val version = testHelper.doSync<KeysVersion> { + bobKeysBackupService.createKeysBackupVersion(megolmBackupCreationInfo, it) + } + Log.v("#E2E TEST", "... Key backup started and enabled for bob") + // Bob session should now have + + val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!! + + // let's send a few message to bob + val sentEventIds = mutableListOf<String>() + val messagesText = listOf("1. Hello", "2. Bob", "3. Good morning") + messagesText.forEach { text -> + val sentEventId = sendMessageInRoom(aliceRoomPOV, text)!!.also { + sentEventIds.add(it) + } + + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId) + timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE + } + } + // we want more so let's discard the session + aliceSession.cryptoService().discardOutboundSession(e2eRoomID) + + testHelper.runBlockingTest { + delay(1_000) + } + } + Log.v("#E2E TEST", "Bob received all and can decrypt") + + // Let's wait a bit to be sure that bob has backed up the session + + Log.v("#E2E TEST", "Force key backup for Bob...") + testHelper.waitWithLatch { latch -> + bobKeysBackupService.backupAllGroupSessions( + null, + TestMatrixCallback(latch, true) + ) + } + Log.v("#E2E TEST", "... Key backup done for Bob") + + // Now lets logout both alice and bob to ensure that we won't have any gossiping + + val bobUserId = bobSession.myUserId + Log.v("#E2E TEST", "Logout alice and bob...") + testHelper.signOutAndClose(aliceSession) + testHelper.signOutAndClose(bobSession) + Log.v("#E2E TEST", "..Logout alice and bob...") + + testHelper.runBlockingTest { + delay(1_000) + } + + // Create a new session for bob + Log.v("#E2E TEST", "Create a new session for Bob") + val newBobSession = testHelper.logIntoAccount(bobUserId, SessionTestParams(true)) + + // check that bob can't currently decrypt + Log.v("#E2E TEST", "check that bob can't currently decrypt") + sentEventIds.forEach { sentEventId -> + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId)?.also { + Log.v("#E2E TEST", "Event seen by new user ${it.root.getClearType()}|${it.root.mCryptoError}") + } + timelineEvent != null && + timelineEvent.root.getClearType() == EventType.ENCRYPTED + } + } + } + // after initial sync events are not decrypted, so we have to try manually + ensureCannotDecrypt(sentEventIds, newBobSession, e2eRoomID, MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID) + + // Let's now import keys from backup + + newBobSession.cryptoService().keysBackupService().let { keysBackupService -> + val keyVersionResult = testHelper.doSync<KeysVersionResult?> { + keysBackupService.getVersion(version.version, it) + } + + val importedResult = testHelper.doSync<ImportRoomKeysResult> { + keysBackupService.restoreKeyBackupWithPassword(keyVersionResult!!, + keyBackupPassword, + null, + null, + null, it) + } + + assertEquals(3, importedResult.totalNumberOfKeys) + } + + // ensure bob can now decrypt + ensureCanDecrypt(sentEventIds, newBobSession, e2eRoomID, messagesText) + + testHelper.signOutAndClose(newBobSession) + } + + /** + * Check that a new verified session that was not supposed to get the keys initially will + * get them from an older one. + */ + @Test + fun testSimpleGossip() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + val aliceSession = cryptoTestData.firstSession + val bobSession = cryptoTestData.secondSession!! + val e2eRoomID = cryptoTestData.roomId + + val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!! + + cryptoTestHelper.initializeCrossSigning(bobSession) + + // let's send a few message to bob + val sentEventIds = mutableListOf<String>() + val messagesText = listOf("1. Hello", "2. Bob") + + Log.v("#E2E TEST", "Alice sends some messages") + messagesText.forEach { text -> + val sentEventId = sendMessageInRoom(aliceRoomPOV, text)!!.also { + sentEventIds.add(it) + } + + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId) + timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE + } + } + } + + // Ensure bob can decrypt + ensureIsDecrypted(sentEventIds, bobSession, e2eRoomID) + + // Let's now add a new bob session + // Create a new session for bob + Log.v("#E2E TEST", "Create a new session for Bob") + val newBobSession = testHelper.logIntoAccount(bobSession.myUserId, SessionTestParams(true)) + + // check that new bob can't currently decrypt + Log.v("#E2E TEST", "check that new bob can't currently decrypt") + + ensureCannotDecrypt(sentEventIds, newBobSession, e2eRoomID, MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID) + + // Try to request + sentEventIds.forEach { sentEventId -> + val event = newBobSession.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root + newBobSession.cryptoService().requestRoomKeyForEvent(event) + } + + // wait a bit + testHelper.runBlockingTest { + delay(10_000) + } + + // Ensure that new bob still can't decrypt (keys must have been withheld) + ensureCannotDecrypt(sentEventIds, newBobSession, e2eRoomID, MXCryptoError.ErrorType.KEYS_WITHHELD) + + // Now mark new bob session as verified + + bobSession.cryptoService().verificationService().markedLocallyAsManuallyVerified(newBobSession.myUserId, newBobSession.sessionParams.deviceId!!) + newBobSession.cryptoService().verificationService().markedLocallyAsManuallyVerified(bobSession.myUserId, bobSession.sessionParams.deviceId!!) + + // now let new session re-request + sentEventIds.forEach { sentEventId -> + val event = newBobSession.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root + newBobSession.cryptoService().reRequestRoomKeyForEvent(event) + } + + // wait a bit + testHelper.runBlockingTest { + delay(10_000) + } + + ensureCanDecrypt(sentEventIds, newBobSession, e2eRoomID, messagesText) + + cryptoTestData.cleanUp(testHelper) + testHelper.signOutAndClose(newBobSession) + } + + /** + * Test that if a better key is forwarded (lower index, it is then used) + */ + @Test + fun testForwardBetterKey() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + val aliceSession = cryptoTestData.firstSession + val bobSessionWithBetterKey = cryptoTestData.secondSession!! + val e2eRoomID = cryptoTestData.roomId + + val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!! + + cryptoTestHelper.initializeCrossSigning(bobSessionWithBetterKey) + + // let's send a few message to bob + var firstEventId: String + val firstMessage = "1. Hello" + + Log.v("#E2E TEST", "Alice sends some messages") + firstMessage.let { text -> + firstEventId = sendMessageInRoom(aliceRoomPOV, text)!! + + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = bobSessionWithBetterKey.getRoom(e2eRoomID)?.getTimelineEvent(firstEventId) + timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE + } + } + } + + // Ensure bob can decrypt + ensureIsDecrypted(listOf(firstEventId), bobSessionWithBetterKey, e2eRoomID) + + // Let's add a new unverified session from bob + val newBobSession = testHelper.logIntoAccount(bobSessionWithBetterKey.myUserId, SessionTestParams(true)) + + // check that new bob can't currently decrypt + Log.v("#E2E TEST", "check that new bob can't currently decrypt") + ensureCannotDecrypt(listOf(firstEventId), newBobSession, e2eRoomID, MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID) + + // Now let alice send a new message. this time the new bob session will be able to decrypt + var secondEventId: String + val secondMessage = "2. New Device?" + + Log.v("#E2E TEST", "Alice sends some messages") + secondMessage.let { text -> + secondEventId = sendMessageInRoom(aliceRoomPOV, text)!! + + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(secondEventId) + timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE + } + } + } + + // check that both messages have same sessionId (it's just that we don't have index 0) + val firstEventNewBobPov = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(firstEventId) + val secondEventNewBobPov = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(secondEventId) + + val firstSessionId = firstEventNewBobPov!!.root.content.toModel<EncryptedEventContent>()!!.sessionId!! + val secondSessionId = secondEventNewBobPov!!.root.content.toModel<EncryptedEventContent>()!!.sessionId!! + + Assert.assertTrue("Should be the same session id", firstSessionId == secondSessionId) + + // Confirm we can decrypt one but not the other + testHelper.runBlockingTest { + try { + newBobSession.cryptoService().decryptEvent(firstEventNewBobPov.root, "") + fail("Should not be able to decrypt event") + } catch (error: MXCryptoError) { + val errorType = (error as? MXCryptoError.Base)?.errorType + assertEquals(MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX, errorType) + } + } + + testHelper.runBlockingTest { + try { + newBobSession.cryptoService().decryptEvent(secondEventNewBobPov.root, "") + } catch (error: MXCryptoError) { + fail("Should be able to decrypt event") + } + } + + // Now let's verify bobs session, and re-request keys + bobSessionWithBetterKey.cryptoService() + .verificationService() + .markedLocallyAsManuallyVerified(newBobSession.myUserId, newBobSession.sessionParams.deviceId!!) + + newBobSession.cryptoService() + .verificationService() + .markedLocallyAsManuallyVerified(bobSessionWithBetterKey.myUserId, bobSessionWithBetterKey.sessionParams.deviceId!!) + + // now let new session request + newBobSession.cryptoService().requestRoomKeyForEvent(firstEventNewBobPov.root) + + // wait a bit + testHelper.runBlockingTest { + delay(10_000) + } + + // old session should have shared the key at earliest known index now + // we should be able to decrypt both + testHelper.runBlockingTest { + try { + newBobSession.cryptoService().decryptEvent(firstEventNewBobPov.root, "") + } catch (error: MXCryptoError) { + fail("Should be able to decrypt first event now $error") + } + } + testHelper.runBlockingTest { + try { + newBobSession.cryptoService().decryptEvent(secondEventNewBobPov.root, "") + } catch (error: MXCryptoError) { + fail("Should be able to decrypt event $error") + } + } + + cryptoTestData.cleanUp(testHelper) + testHelper.signOutAndClose(newBobSession) + } + + private fun sendMessageInRoom(aliceRoomPOV: Room, text: String): String? { + aliceRoomPOV.sendTextMessage(text) + var sentEventId: String? = null + testHelper.waitWithLatch(4 * TestConstants.timeOutMillis) { latch -> + val timeline = aliceRoomPOV.createTimeline(null, TimelineSettings(60)) + timeline.start() + + testHelper.retryPeriodicallyWithLatch(latch) { + val decryptedMsg = timeline.getSnapshot() + .filter { it.root.getClearType() == EventType.MESSAGE } + .also { list -> + val message = list.joinToString(",", "[", "]") { "${it.root.type}|${it.root.sendState}" } + Log.v("#E2E TEST", "Timeline snapshot is $message") + } + .filter { it.root.sendState == SendState.SYNCED } + .firstOrNull { it.root.getClearContent().toModel<MessageContent>()?.body?.startsWith(text) == true } + sentEventId = decryptedMsg?.eventId + decryptedMsg != null + } + + timeline.dispose() + } + return sentEventId + } + + private fun ensureMembersHaveJoined(aliceSession: Session, otherAccounts: List<Session>, e2eRoomID: String) { + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + otherAccounts.map { + aliceSession.getRoomMember(it.myUserId, e2eRoomID)?.membership + }.all { + it == Membership.JOIN + } + } + } + } + + private fun waitForAndAcceptInviteInRoom(otherSession: Session, e2eRoomID: String) { + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val roomSummary = otherSession.getRoomSummary(e2eRoomID) + (roomSummary != null && roomSummary.membership == Membership.INVITE).also { + if (it) { + Log.v("#E2E TEST", "${otherSession.myUserId} can see the invite from alice") + } + } + } + } + + testHelper.runBlockingTest(60_000) { + Log.v("#E2E TEST", "${otherSession.myUserId} tries to join room $e2eRoomID") + try { + otherSession.joinRoom(e2eRoomID) + } catch (ex: JoinRoomFailure.JoinedWithTimeout) { + // it's ok we will wait after + } + } + + Log.v("#E2E TEST", "${otherSession.myUserId} waiting for join echo ...") + testHelper.waitWithLatch { + testHelper.retryPeriodicallyWithLatch(it) { + val roomSummary = otherSession.getRoomSummary(e2eRoomID) + roomSummary != null && roomSummary.membership == Membership.JOIN + } + } + } + + private fun ensureCanDecrypt(sentEventIds: MutableList<String>, session: Session, e2eRoomID: String, messagesText: List<String>) { + sentEventIds.forEachIndexed { index, sentEventId -> + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val event = session.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root + testHelper.runBlockingTest { + try { + session.cryptoService().decryptEvent(event, "").let { result -> + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + } + } catch (error: MXCryptoError) { + // nop + } + } + event.getClearType() == EventType.MESSAGE && + messagesText[index] == event.getClearContent()?.toModel<MessageContent>()?.body + } + } + } + } + + private fun ensureIsDecrypted(sentEventIds: List<String>, session: Session, e2eRoomID: String) { + testHelper.waitWithLatch { latch -> + sentEventIds.forEach { sentEventId -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = session.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId) + timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE + } + } + } + } + + private fun ensureCannotDecrypt(sentEventIds: List<String>, newBobSession: Session, e2eRoomID: String, expectedError: MXCryptoError.ErrorType?) { + sentEventIds.forEach { sentEventId -> + val event = newBobSession.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root + testHelper.runBlockingTest { + try { + newBobSession.cryptoService().decryptEvent(event, "") + fail("Should not be able to decrypt event") + } catch (error: MXCryptoError) { + val errorType = (error as? MXCryptoError.Base)?.errorType + if (expectedError == null) { + Assert.assertNotNull(errorType) + } else { + assertEquals(expectedError, errorType, "Message expected to be UISI") + } + } + } + } + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt index a7a81bacf50bd5d4775288d3b1e42130ba944625..46c1dacf782ec1b73a4a428d12fd64410f24f36e 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt @@ -21,7 +21,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.FixMethodOrder -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.MethodSorters @@ -41,7 +40,6 @@ class PreShareKeysTest : InstrumentedTest { private val cryptoTestHelper = CryptoTestHelper(testHelper) @Test - @Ignore("This test will be ignored until it is fixed") fun ensure_outbound_session_happy_path() { val testData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) val e2eRoomID = testData.roomId @@ -92,7 +90,7 @@ class PreShareKeysTest : InstrumentedTest { // Just send a real message as test val sentEvent = testHelper.sendTextMessage(aliceSession.getRoom(e2eRoomID)!!, "Allo", 1).first() - assertEquals(megolmSessionId, sentEvent.root.content.toModel<EncryptedEventContent>()?.sessionId, "Unexpected megolm session") + assertEquals("Unexpected megolm session", megolmSessionId, sentEvent.root.content.toModel<EncryptedEventContent>()?.sessionId,) testHelper.waitWithLatch { latch -> testHelper.retryPeriodicallyWithLatch(latch) { bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEvent.eventId)?.root?.getClearType() == EventType.MESSAGE diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt index 0a8ce6768078f5d9160ec060853821990abbacf3..fb5d58b127f5440dc8ca40f189ca19a9a8ef1bad 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt @@ -21,7 +21,6 @@ import org.amshove.kluent.shouldBe import org.junit.Assert import org.junit.Before import org.junit.FixMethodOrder -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.MethodSorters @@ -85,7 +84,6 @@ class UnwedgingTest : InstrumentedTest { * -> This is automatically fixed after SDKs restarted the olm session */ @Test - @Ignore("This test will be ignored until it is fixed") fun testUnwedging() { val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom() @@ -94,9 +92,7 @@ class UnwedgingTest : InstrumentedTest { val bobSession = cryptoTestData.secondSession!! val aliceCryptoStore = (aliceSession.cryptoService() as DefaultCryptoService).cryptoStoreForTesting - - // bobSession.cryptoService().setWarnOnUnknownDevices(false) - // aliceSession.cryptoService().setWarnOnUnknownDevices(false) + val olmDevice = (aliceSession.cryptoService() as DefaultCryptoService).olmDeviceForTest val roomFromBobPOV = bobSession.getRoom(aliceRoomId)!! val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! @@ -175,6 +171,7 @@ class UnwedgingTest : InstrumentedTest { Timber.i("## CRYPTO | testUnwedging: wedge the session now. Set crypto state like after the first message") aliceCryptoStore.storeSession(OlmSessionWrapper(deserializeFromRealm<OlmSession>(oldSession)!!), bobSession.cryptoService().getMyDevice().identityKey()!!) + olmDevice.clearOlmSessionCache() Thread.sleep(6_000) // Force new session, and key share @@ -227,8 +224,10 @@ class UnwedgingTest : InstrumentedTest { testHelper.waitWithLatch { testHelper.retryPeriodicallyWithLatch(it) { // we should get back the key and be able to decrypt - val result = tryOrNull { - bobSession.cryptoService().decryptEvent(messagesReceivedByBob[0].root, "") + val result = testHelper.runBlockingTest { + tryOrNull { + bobSession.cryptoService().decryptEvent(messagesReceivedByBob[0].root, "") + } } Timber.i("## CRYPTO | testUnwedging: decrypt result ${result?.clearEvent}") result != null diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt index 82aee454eb361657f26b6f68748290b7272cfca4..cd20ab477ca535b0e30a4fa3d9ec8fb0d95681a9 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt @@ -97,7 +97,9 @@ class KeyShareTests : InstrumentedTest { assert(receivedEvent!!.isEncrypted()) try { - aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") + commonTestHelper.runBlockingTest { + aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") + } fail("should fail") } catch (failure: Throwable) { } @@ -152,7 +154,9 @@ class KeyShareTests : InstrumentedTest { } try { - aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") + commonTestHelper.runBlockingTest { + aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") + } fail("should fail") } catch (failure: Throwable) { } @@ -189,7 +193,9 @@ class KeyShareTests : InstrumentedTest { } try { - aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") + commonTestHelper.runBlockingTest { + aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") + } } catch (failure: Throwable) { fail("should have been able to decrypt") } @@ -384,7 +390,11 @@ class KeyShareTests : InstrumentedTest { val roomRoomBobPov = aliceSession.getRoom(roomId) val beforeJoin = roomRoomBobPov!!.getTimelineEvent(secondEventId) - var dRes = tryOrNull { bobSession.cryptoService().decryptEvent(beforeJoin!!.root, "") } + var dRes = tryOrNull { + commonTestHelper.runBlockingTest { + bobSession.cryptoService().decryptEvent(beforeJoin!!.root, "") + } + } assert(dRes == null) @@ -395,7 +405,11 @@ class KeyShareTests : InstrumentedTest { Thread.sleep(3_000) // With the bug the first session would have improperly reshare that key :/ - dRes = tryOrNull { bobSession.cryptoService().decryptEvent(beforeJoin.root, "") } + dRes = tryOrNull { + commonTestHelper.runBlockingTest { + bobSession.cryptoService().decryptEvent(beforeJoin.root, "") + } + } Log.d("#TEST", "KS: sgould not decrypt that ${beforeJoin.root.getClearContent().toModel<MessageContent>()?.body}") assert(dRes?.clearEvent == null) } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt index 9fda21763a023c94f0d01f55cad1956ef9599d4a..e8f6eea460e33441ceccbbcec20db4aeb279f26f 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt @@ -93,7 +93,9 @@ class WithHeldTests : InstrumentedTest { // Bob should not be able to decrypt because the keys is withheld try { // .. might need to wait a bit for stability? - bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "") + testHelper.runBlockingTest { + bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "") + } Assert.fail("This session should not be able to decrypt") } catch (failure: Throwable) { val type = (failure as MXCryptoError.Base).errorType @@ -118,7 +120,9 @@ class WithHeldTests : InstrumentedTest { // Previous message should still be undecryptable (partially withheld session) try { // .. might need to wait a bit for stability? - bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "") + testHelper.runBlockingTest { + bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "") + } Assert.fail("This session should not be able to decrypt") } catch (failure: Throwable) { val type = (failure as MXCryptoError.Base).errorType @@ -134,7 +138,7 @@ class WithHeldTests : InstrumentedTest { @Test @Ignore("This test will be ignored until it is fixed") - fun test_WithHeldNoOlm() { + fun test_WithHeldNoOlm() { val testData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom() val aliceSession = testData.firstSession val bobSession = testData.secondSession!! @@ -165,7 +169,9 @@ class WithHeldTests : InstrumentedTest { val eventBobPOV = bobSession.getRoom(testData.roomId)?.getTimelineEvent(eventId) try { // .. might need to wait a bit for stability? - bobSession.cryptoService().decryptEvent(eventBobPOV!!.root, "") + testHelper.runBlockingTest { + bobSession.cryptoService().decryptEvent(eventBobPOV!!.root, "") + } Assert.fail("This session should not be able to decrypt") } catch (failure: Throwable) { val type = (failure as MXCryptoError.Base).errorType @@ -233,7 +239,11 @@ class WithHeldTests : InstrumentedTest { testHelper.retryPeriodicallyWithLatch(latch) { val timeLineEvent = bobSecondSession.getRoom(testData.roomId)?.getTimelineEvent(eventId)?.also { // try to decrypt and force key request - tryOrNull { bobSecondSession.cryptoService().decryptEvent(it.root, "") } + tryOrNull { + testHelper.runBlockingTest { + bobSecondSession.cryptoService().decryptEvent(it.root, "") + } + } } sessionId = timeLineEvent?.root?.content?.toModel<EncryptedEventContent>()?.sessionId timeLineEvent != null 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 35c5a4dab9cf72b0b99f6c3a88978f11b8bfc1f5..2c96568102904016eafb7439c897c9cf87011eee 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 @@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.crypto.verification.qrcode import androidx.test.ext.junit.runners.AndroidJUnit4 import org.amshove.kluent.shouldBe import org.junit.FixMethodOrder +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.MethodSorters @@ -39,6 +40,7 @@ import kotlin.coroutines.resume @RunWith(AndroidJUnit4::class) @FixMethodOrder(MethodSorters.JVM) +@Ignore("This test is flaky ; see issue #5449") class VerificationTest : InstrumentedTest { data class ExpectedResult( diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt index 9856ee777068bc07c84dda507f299eef25f13845..1e3512a9df59e0d450729036c5761722cd3f7f73 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt @@ -60,7 +60,9 @@ class MarkdownParserTest : InstrumentedTest { applicationFlavor = "TestFlavor", roomDisplayNameFallbackProvider = TestRoomDisplayNameFallbackProvider() ) - )) + ), + TestPermalinkService() + ) ) @Test diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/TestPermalinkService.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/TestPermalinkService.kt new file mode 100644 index 0000000000000000000000000000000000000000..2f9a5e0a73a656f0f00dfdf048fafd85b5e252d2 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/TestPermalinkService.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 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.send + +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.permalinks.PermalinkService +import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.HTML +import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.MARKDOWN + +class TestPermalinkService : PermalinkService { + override fun createPermalink(event: Event, forceMatrixTo: Boolean): String? { + return null + } + + override fun createPermalink(id: String, forceMatrixTo: Boolean): String? { + return "" + } + + override fun createPermalink(roomId: String, eventId: String, forceMatrixTo: Boolean): String { + return "" + } + + override fun createRoomPermalink(roomId: String, viaServers: List<String>?, forceMatrixTo: Boolean): String? { + return null + } + + override fun getLinkedId(url: String): String? { + return null + } + + override fun createMentionSpanTemplate(type: PermalinkService.SpanTemplateType, forceMatrixTo: Boolean): String { + return when (type) { + HTML -> "<a href=\"https://matrix.to/#/%1\$s\">%2\$s</a>" + MARKDOWN -> "[%2\$s](https://matrix.to/#/%1\$s)" + } + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt index 69ae57e644f6d5b79559ee7f97e50135b9c00c96..5c011c8b2fb195711e3afe55bc2f31b389912573 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt @@ -62,7 +62,11 @@ internal class ChunkEntityTest : InstrumentedTest { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { realm.copyToRealm(it) } - chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) + chunk.addTimelineEvent( + roomId = ROOM_ID, + eventEntity = fakeEvent, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = emptyMap()) chunk.timelineEvents.size shouldBeEqualTo 1 } } @@ -74,8 +78,16 @@ internal class ChunkEntityTest : InstrumentedTest { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { realm.copyToRealm(it) } - chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) - chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) + chunk.addTimelineEvent( + roomId = ROOM_ID, + eventEntity = fakeEvent, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = emptyMap()) + chunk.addTimelineEvent( + roomId = ROOM_ID, + eventEntity = fakeEvent, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = emptyMap()) chunk.timelineEvents.size shouldBeEqualTo 1 } } @@ -144,7 +156,11 @@ internal class ChunkEntityTest : InstrumentedTest { val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let { realm.copyToRealm(it) } - addTimelineEvent(roomId, fakeEvent, direction, emptyMap()) + addTimelineEvent( + roomId = roomId, + eventEntity = fakeEvent, + direction = direction, + roomMemberContentsByUser = emptyMap()) } } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..6792d6ddfd039024870c2434b36cd2efb32b3399 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright 2020 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.session.room.timeline + +import org.amshove.kluent.fail +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeGreaterThan +import org.amshove.kluent.shouldContain +import org.amshove.kluent.shouldContainAll +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +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.room.model.PollResponseAggregatedSummary +import org.matrix.android.sdk.api.session.room.model.PollSummaryContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.PollType +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CryptoTestHelper +import java.util.concurrent.CountDownLatch + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class PollAggregationTest : InstrumentedTest { + + @Test + fun testAllPollUseCases() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + + val roomFromBobPOV = cryptoTestData.secondSession!!.getRoom(cryptoTestData.roomId)!! + // Bob creates a poll + roomFromBobPOV.sendPoll(PollType.DISCLOSED, pollQuestion, pollOptions) + + aliceSession.startSync(true) + val aliceTimeline = roomFromAlicePOV.createTimeline(null, TimelineSettings(30)) + aliceTimeline.start() + + val TOTAL_TEST_COUNT = 7 + val lock = CountDownLatch(TOTAL_TEST_COUNT) + + val aliceEventsListener = object : Timeline.Listener { + override fun onTimelineUpdated(snapshot: List<TimelineEvent>) { + snapshot.firstOrNull { it.root.getClearType() in EventType.POLL_START }?.let { pollEvent -> + val pollEventId = pollEvent.eventId + val pollContent = pollEvent.root.content?.toModel<MessagePollContent>() + val pollSummary = pollEvent.annotations?.pollResponseSummary + + if (pollContent == null) { + fail("Poll content is null") + return + } + + when (lock.count.toInt()) { + TOTAL_TEST_COUNT -> { + // Poll has just been created. + testInitialPollConditions(pollContent, pollSummary) + lock.countDown() + roomFromBobPOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.firstOrNull()?.id ?: "") + } + TOTAL_TEST_COUNT - 1 -> { + // Bob: Option 1 + testBobVotesOption1(pollContent, pollSummary) + lock.countDown() + roomFromBobPOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id ?: "") + } + TOTAL_TEST_COUNT - 2 -> { + // Bob: Option 2 + testBobChangesVoteToOption2(pollContent, pollSummary) + lock.countDown() + roomFromAlicePOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id ?: "") + } + TOTAL_TEST_COUNT - 3 -> { + // Alice: Option 2, Bob: Option 2 + testAliceAndBobVoteToOption2(pollContent, pollSummary) + lock.countDown() + roomFromAlicePOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.firstOrNull()?.id ?: "") + } + TOTAL_TEST_COUNT - 4 -> { + // Alice: Option 1, Bob: Option 2 + testAliceVotesOption1AndBobVotesOption2(pollContent, pollSummary) + lock.countDown() + roomFromBobPOV.endPoll(pollEventId) + } + TOTAL_TEST_COUNT - 5 -> { + // Alice: Option 1, Bob: Option 2 [poll is ended] + testEndedPoll(pollSummary) + lock.countDown() + roomFromAlicePOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id ?: "") + } + TOTAL_TEST_COUNT - 6 -> { + // Alice: Option 1 (ignore change), Bob: Option 2 [poll is ended] + testAliceVotesOption1AndBobVotesOption2(pollContent, pollSummary) + testEndedPoll(pollSummary) + lock.countDown() + } + else -> { + fail("Lock count ${lock.count} didn't handled.") + } + } + } + } + } + + aliceTimeline.addListener(aliceEventsListener) + + commonTestHelper.await(lock) + + aliceTimeline.removeAllListeners() + + aliceSession.stopSync() + aliceTimeline.dispose() + cryptoTestData.cleanUp(commonTestHelper) + } + + private fun testInitialPollConditions(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) { + // No votes yet, poll summary should be null + pollSummary shouldBe null + // Question should be the same as intended + pollContent.getBestPollCreationInfo()?.question?.getBestQuestion() shouldBeEqualTo pollQuestion + // Options should be the same as intended + pollContent.getBestPollCreationInfo()?.answers?.let { answers -> + answers.size shouldBeEqualTo pollOptions.size + answers.map { it.getBestAnswer() } shouldContainAll pollOptions + } + } + + private fun testBobVotesOption1(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) { + if (pollSummary == null) { + fail("Poll summary shouldn't be null when someone votes") + return + } + val answerId = pollContent.getBestPollCreationInfo()?.answers?.first()?.id + // Check if the intended vote is in poll summary + pollSummary.aggregatedContent?.let { aggregatedContent -> + assertTotalVotesCount(aggregatedContent, 1) + aggregatedContent.votes?.first()?.option shouldBeEqualTo answerId + aggregatedContent.votesSummary?.get(answerId)?.total shouldBeEqualTo 1 + aggregatedContent.votesSummary?.get(answerId)?.percentage shouldBeEqualTo 1.0 + } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") } + } + + private fun testBobChangesVoteToOption2(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) { + if (pollSummary == null) { + fail("Poll summary shouldn't be null when someone votes") + return + } + val answerId = pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id + // Check if the intended vote is in poll summary + pollSummary.aggregatedContent?.let { aggregatedContent -> + assertTotalVotesCount(aggregatedContent, 1) + aggregatedContent.votes?.first()?.option shouldBeEqualTo answerId + aggregatedContent.votesSummary?.get(answerId)?.total shouldBeEqualTo 1 + aggregatedContent.votesSummary?.get(answerId)?.percentage shouldBeEqualTo 1.0 + } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") } + } + + private fun testAliceAndBobVoteToOption2(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) { + if (pollSummary == null) { + fail("Poll summary shouldn't be null when someone votes") + return + } + val answerId = pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id + // Check if the intended votes is in poll summary + pollSummary.aggregatedContent?.let { aggregatedContent -> + assertTotalVotesCount(aggregatedContent, 2) + aggregatedContent.votes?.first()?.option shouldBeEqualTo answerId + aggregatedContent.votes?.get(1)?.option shouldBeEqualTo answerId + aggregatedContent.votesSummary?.get(answerId)?.total shouldBeEqualTo 2 + aggregatedContent.votesSummary?.get(answerId)?.percentage shouldBeEqualTo 1.0 + } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") } + } + + private fun testAliceVotesOption1AndBobVotesOption2(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) { + if (pollSummary == null) { + fail("Poll summary shouldn't be null when someone votes") + return + } + val firstAnswerId = pollContent.getBestPollCreationInfo()?.answers?.firstOrNull()?.id + val secondAnswerId = pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id + // Check if the intended votes is in poll summary + pollSummary.aggregatedContent?.let { aggregatedContent -> + assertTotalVotesCount(aggregatedContent, 2) + aggregatedContent.votes!!.map { it.option } shouldContain firstAnswerId + aggregatedContent.votes!!.map { it.option } shouldContain secondAnswerId + aggregatedContent.votesSummary?.get(firstAnswerId)?.total shouldBeEqualTo 1 + aggregatedContent.votesSummary?.get(secondAnswerId)?.total shouldBeEqualTo 1 + aggregatedContent.votesSummary?.get(firstAnswerId)?.percentage shouldBeEqualTo 0.5 + aggregatedContent.votesSummary?.get(secondAnswerId)?.percentage shouldBeEqualTo 0.5 + } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") } + } + + private fun testEndedPoll(pollSummary: PollResponseAggregatedSummary?) { + pollSummary?.closedTime ?: 0 shouldBeGreaterThan 0 + } + + private fun assertTotalVotesCount(aggregatedContent: PollSummaryContent, expectedVoteCount: Int) { + aggregatedContent.totalVotes shouldBeEqualTo expectedVoteCount + aggregatedContent.votes?.size shouldBeEqualTo expectedVoteCount + } + + companion object { + const val pollQuestion = "Do you like creating polls?" + val pollOptions = listOf("Yes", "Absolutely", "As long as tests pass") + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index c87f21d7ac3a5b1d6e475c855e2ab083848a085e..c4bc289b7532ee56802a8b2be2747996db68ede1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -60,7 +60,15 @@ data class MatrixConfiguration( /** * RoomDisplayNameFallbackProvider to provide default room display name. */ - val roomDisplayNameFallbackProvider: RoomDisplayNameFallbackProvider + val roomDisplayNameFallbackProvider: RoomDisplayNameFallbackProvider, + /** + * True to enable presence information sync (if available). False to disable regardless of server setting. + */ + val presenceSyncEnabled: Boolean = true, + /** + * Thread messages default enable/disabled value + */ + val threadMessagesEnabledDefault: Boolean = false, ) { /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt index aabe6e0d0698c37280687c39034cd9f9f2dce055..89b4a343dd4fd93b6d8338c673acd5588395104b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt @@ -58,12 +58,36 @@ fun Throwable.getRetryDelay(defaultValue: Long): Long { ?: defaultValue } +fun Throwable.isUsernameInUse(): Boolean { + return this is Failure.ServerError && error.code == MatrixError.M_USER_IN_USE +} + +fun Throwable.isInvalidUsername(): Boolean { + return this is Failure.ServerError && + error.code == MatrixError.M_INVALID_USERNAME +} + fun Throwable.isInvalidPassword(): Boolean { return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN && error.message == "Invalid password" } +fun Throwable.isRegistrationDisabled(): Boolean { + return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN && + httpCode == HttpsURLConnection.HTTP_FORBIDDEN +} + +fun Throwable.isWeakPassword(): Boolean { + return this is Failure.ServerError && error.code == MatrixError.M_WEAK_PASSWORD +} + +fun Throwable.isLoginEmailUnknown(): Boolean { + return this is Failure.ServerError && + error.code == MatrixError.M_FORBIDDEN && + error.message.isEmpty() +} + fun Throwable.isInvalidUIAAuth(): Boolean { return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN && @@ -104,8 +128,8 @@ fun Throwable.isRegistrationAvailabilityError(): Boolean { return this is Failure.ServerError && httpCode == HttpsURLConnection.HTTP_BAD_REQUEST && /* 400 */ (error.code == MatrixError.M_USER_IN_USE || - error.code == MatrixError.M_INVALID_USERNAME || - error.code == MatrixError.M_EXCLUSIVE) + error.code == MatrixError.M_INVALID_USERNAME || + error.code == MatrixError.M_EXCLUSIVE) } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt index e3f00a24b669c6aebad3ff375700e8c26f37506f..a5b442dc4a71c991c678b7ba877326c8c291c8ed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt @@ -121,7 +121,7 @@ interface CryptoService { fun discardOutboundSession(roomId: String) @Throws(MXCryptoError::class) - fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult + suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult fun decryptEventAsync(event: Event, timeline: String, callback: MatrixCallback<MXEventDecryptionResult>) @@ -140,7 +140,6 @@ interface CryptoService { fun getLiveCryptoDeviceInfo(userIds: List<String>): LiveData<List<CryptoDeviceInfo>> fun addNewSessionListener(newSessionListener: NewSessionListener) - fun removeSessionListener(listener: NewSessionListener) fun getOutgoingRoomKeyRequests(): List<OutgoingRoomKeyRequest> diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt index 34096d603fd14b877e4a7ee9811680a2267a717d..ae8ed3941fa3d4396044186eab396204a293791d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt @@ -49,5 +49,6 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class AggregatedRelations( @Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null, - @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null + @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null, + @Json(name = RelationType.THREAD) val latestThread: LatestThreadUnsignedRelation? = null ) 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 df57ca568111d9c5144ee77e168d440dfd8a96f3..f1304f62163755324cb4da42679f1ab9d41d7d86 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 @@ -201,7 +201,11 @@ data class Event( */ fun getDecryptedTextSummary(): String? { if (isRedacted()) return "Message Deleted" - val text = getDecryptedValue() ?: return null + val text = getDecryptedValue() ?: run { + if (isPoll()) { return getPollQuestion() ?: "created a poll." } + return null + } + return when { isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text) isFileMessage() -> "sent a file." @@ -349,7 +353,7 @@ fun Event.isAttachmentMessage(): Boolean { } } -fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END +fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START || getClearType() in EventType.POLL_END fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER @@ -372,7 +376,7 @@ fun Event.getRelationContent(): RelationDefaultContent? { * Returns the poll question or null otherwise */ fun Event.getPollQuestion(): String? = - getPollContent()?.pollCreationInfo?.question?.question + getPollContent()?.getBestPollCreationInfo()?.question?.getBestQuestion() /** * Returns the relation content for a specific type or null otherwise @@ -385,12 +389,12 @@ fun Event.isReply(): Boolean { } fun Event.isReplyRenderedInThread(): Boolean { - return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true + return isReply() && getRelationContent()?.shouldRenderInThread() == true } -fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null +fun Event.isThread(): Boolean = getRelationContentForType(RelationType.THREAD)?.eventId != null -fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_THREAD)?.eventId +fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.THREAD)?.eventId fun Event.isEdition(): Boolean { return getRelationContentForType(RelationType.REPLACE)?.eventId != null @@ -406,3 +410,5 @@ fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && fun Event.getPollContent(): MessagePollContent? { return content.toModel<MessagePollContent>() } + +fun Event.supportsNotification() = this.getClearType() in EventType.MESSAGE + EventType.POLL_START diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 0c77b574e7266dbc71a36c9f615428c3fbed0ea2..855801e79ec0761466c6d2a8771f6c5cc8c65b8d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -49,6 +49,7 @@ object EventType { const val STATE_ROOM_JOIN_RULES = "m.room.join_rules" const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access" const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels" + private const val STATE_ROOM_BEACON_INFO_PREFIX = "org.matrix.msc3489.beacon_info." const val STATE_SPACE_CHILD = "m.space.child" @@ -103,9 +104,9 @@ object EventType { const val REACTION = "m.reaction" // Poll - const val POLL_START = "org.matrix.msc3381.poll.start" - const val POLL_RESPONSE = "org.matrix.msc3381.poll.response" - const val POLL_END = "org.matrix.msc3381.poll.end" + val POLL_START = listOf("org.matrix.msc3381.poll.start", "m.poll.start") + val POLL_RESPONSE = listOf("org.matrix.msc3381.poll.response", "m.poll.response") + val POLL_END = listOf("org.matrix.msc3381.poll.end", "m.poll.end") // Unwedging internal const val DUMMY = "m.dummy" @@ -120,4 +121,12 @@ object EventType { type == CALL_REJECT || type == CALL_REPLACES } + + /** + * Returns an event type like org.matrix.msc3489.beacon_info.@userid:matrix.org.1648814272273 + */ + fun generateBeaconInfoStateEventType(userId: String): String { + val uniqueId = System.currentTimeMillis() + return "$STATE_ROOM_BEACON_INFO_PREFIX$userId.$uniqueId" + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt new file mode 100644 index 0000000000000000000000000000000000000000..cc52dfc02c1a4cf67cf5e4996d098a59f0cb5e0e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt @@ -0,0 +1,30 @@ +/* + * 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.api.session.events.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class LatestThreadUnsignedRelation( + override val limited: Boolean? = false, + override val count: Int? = 0, + @Json(name = "latest_event") + val event: Event? = null, + @Json(name = "current_user_participated") + val isUserParticipating: Boolean? = false + +) : UnsignedRelationInfo diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt index fb26264ad7708992935a5fe0dbdefb363af30964..74dc74b294964e2669c558405f2bec33c59eb386 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt @@ -30,7 +30,6 @@ object RelationType { /** Lets you define an event which is a thread reply to an existing event.*/ const val THREAD = "m.thread" - const val IO_THREAD = "io.element.thread" /** Lets you define an event which adds a response to an existing event.*/ const val RESPONSE = "org.matrix.response" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/UnsignedData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/UnsignedData.kt index dfe1db7b1c272a1f74d474b03efc5452875f4775..630a2fb91a538473d788df6e556c18aa573b3522 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/UnsignedData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/UnsignedData.kt @@ -46,3 +46,5 @@ data class UnsignedData( @Json(name = "replaces_state") val replacesState: String? = null ) + +fun UnsignedData?.isRedacted() = this?.redactedEvent != null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt index 2256dfb8f0c52d0168daebf7e03727e90a72f7ab..9db3876b7472483f1ae712de97fb55fabc7d855d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt @@ -50,7 +50,11 @@ data class HomeServerCapabilities( * This capability describes the default and available room versions a server supports, and at what level of stability. * Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms. */ - val roomVersions: RoomVersionCapabilities? = null + val roomVersions: RoomVersionCapabilities? = null, + /** + * True if the home server support threading + */ + var canUseThreading: Boolean = false ) { enum class RoomCapabilitySupport { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt index 920dc85c7a066d1d5a02381d3bf8b906a79d596c..c139da813ab75484865f9540314de811e48b718a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt @@ -28,6 +28,11 @@ interface PermalinkService { const val MATRIX_TO_URL_BASE = "https://matrix.to/#/" } + enum class SpanTemplateType { + HTML, + MARKDOWN + } + /** * Creates a permalink for an event. * Ex: "https://matrix.to/#/!nbzmcXAqpxBXjAdgoX:matrix.org/$1531497316352799BevdV:matrix.org" @@ -80,4 +85,15 @@ interface PermalinkService { * @return the id from the url, ex: "@benoit:matrix.org", or null if the url is not a permalink */ fun getLinkedId(url: String): String? + + /** + * Creates a HTML or Markdown mention span template. Can be used to replace a mention with a permalink to mentioned user. + * Ex: "<a href=\"https://matrix.to/#/%1\$s\">%2\$s</a>" or "[%2\$s](https://matrix.to/#/%1\$s)" + * + * @param type: type of template to create + * @param forceMatrixTo whether we should force using matrix.to base URL + * + * @return the created template + */ + fun createMentionSpanTemplate(type: SpanTemplateType, forceMatrixTo: Boolean = false): String } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/profile/ProfileService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/profile/ProfileService.kt index 05fa24946a3fe21f61cfb993f5662e9df48ea29d..d2c677bb31425be809668548914608b3334a425a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/profile/ProfileService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/profile/ProfileService.kt @@ -21,6 +21,7 @@ import android.net.Uri import androidx.lifecycle.LiveData import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.session.identity.ThreePid +import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.Optional @@ -118,4 +119,17 @@ interface ProfileService { * Remove a 3Pid from the Matrix account. */ suspend fun deleteThreePid(threePid: ThreePid) + + /** + * Return a User object from a userId + */ + suspend fun getProfileAsUser(userId: String): User { + return getProfile(userId).let { dict -> + User( + userId = userId, + displayName = dict[DISPLAY_NAME_KEY] as? String, + avatarUrl = dict[AVATAR_URL_KEY] as? String + ) + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index d930a5d0fd6cb4d392c2097b8ef7b1afa9bdb141..be65b883b36238509027a6398e61652138232e7f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService import org.matrix.android.sdk.api.session.room.threads.ThreadsService +import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -47,6 +48,7 @@ import org.matrix.android.sdk.api.util.Optional interface Room : TimelineService, ThreadsService, + ThreadsLocalService, SendService, DraftService, ReadService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt index bca432320d92d456095240ef912163a392f30680..c1c1a385b5c088a9ba256603d0367207eca2a7c4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt @@ -216,6 +216,12 @@ interface RoomService { pagedListConfig: PagedList.Config = defaultPagedListConfig, sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): UpdatableLivePageResult + /** + * Return a LiveData on the number of rooms + * @param queryParams parameters to query the room summaries. It can be use to keep only joined rooms, for instance. + */ + fun getRoomCountLive(queryParams: RoomSummaryQueryParams): LiveData<Int> + /** * TODO Doc */ @@ -236,4 +242,12 @@ interface RoomService { */ fun getFlattenRoomSummaryChildrenOfLive(spaceId: String?, memberships: List<Membership> = Membership.activeMemberships()): LiveData<List<RoomSummary>> + + /** + * Refreshes the RoomSummary LatestPreviewContent for the given @param roomId + * If the roomId is null, all rooms are updated + * + * This is useful for refreshing summary content with encrypted messages after receiving new room keys + */ + fun refreshJoinedRoomSummaryPreviews(roomId: String?) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt index b83f57f5ef83ac73626fc8d19118f81eb96eb151..db87f913b93df9975c027dbe93a398905c9c6871 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt @@ -22,10 +22,8 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary interface UpdatableLivePageResult { val livePagedList: LiveData<PagedList<RoomSummary>> - - fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) - val liveBoundaries: LiveData<ResultBoundaries> + var queryParams: RoomSummaryQueryParams } data class ResultBoundaries( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/BeaconInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/BeaconInfo.kt new file mode 100644 index 0000000000000000000000000000000000000000..873edc0f1fd3ac2976021ea87351039792b8f3e1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/BeaconInfo.kt @@ -0,0 +1,33 @@ +/* + * 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.api.session.room.model.livelocation + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class BeaconInfo( + @Json(name = "description") val description: String? = null, + /** + * Beacon should be considered as inactive after this timeout as milliseconds. + */ + @Json(name = "timeout") val timeout: Long? = null, + /** + * Should be set true to start sharing beacon. + */ + @Json(name = "live") val isLive: Boolean? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/LiveLocationBeaconContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/LiveLocationBeaconContent.kt new file mode 100644 index 0000000000000000000000000000000000000000..e08d5b629b54786746b6fe8f959974e2d3e57e6d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/livelocation/LiveLocationBeaconContent.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 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.api.session.room.model.livelocation + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.room.model.message.LocationAsset +import org.matrix.android.sdk.api.session.room.model.message.LocationAssetType + +@JsonClass(generateAdapter = true) +data class LiveLocationBeaconContent( + /** + * Indicates user's intent to share ephemeral location. + */ + @Json(name = "org.matrix.msc3489.beacon_info") val unstableBeaconInfo: BeaconInfo? = null, + @Json(name = "m.beacon_info") val beaconInfo: BeaconInfo? = null, + /** + * Beacon creation timestamp. + */ + @Json(name = "org.matrix.msc3488.ts") val unstableTimestampAsMilliseconds: Long? = null, + @Json(name = "m.ts") val timestampAsMilliseconds: Long? = null, + /** + * Live location asset type. + */ + @Json(name = "org.matrix.msc3488.asset") val unstableLocationAsset: LocationAsset = LocationAsset(LocationAssetType.SELF), + @Json(name = "m.asset") val locationAsset: LocationAsset? = null +) { + + fun getBestBeaconInfo() = beaconInfo ?: unstableBeaconInfo + + fun getBestTimestampAsMilliseconds() = timestampAsMilliseconds ?: unstableTimestampAsMilliseconds + + fun getBestLocationAsset() = locationAsset ?: unstableLocationAsset +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt index e8b3cf2488860a0bc6019d80db8844f771069546..35fa555a5bab38c4bf210c6c61dbd4aa6b411c1b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt @@ -21,5 +21,5 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class LocationAsset( - @Json(name = "type") val type: LocationAssetType? = null + @Json(name = "type") val type: String? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt index ef40e21c47f582889fd015801131a937be268e20..f7d82d4b40909d1f59e3637e128a417191ecc883 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt @@ -16,11 +16,20 @@ package org.matrix.android.sdk.api.session.room.model.message -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass +/** + * Define what particular asset is being referred to. + * We don't use enum type since it is not limited to a specific set of values. + * The way this type should be interpreted in client side is described in + * [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md) + */ +object LocationAssetType { + /** + * Used for user location sharing. + **/ + const val SELF = "m.self" -@JsonClass(generateAdapter = false) -enum class LocationAssetType { - @Json(name = "m.self") - SELF + /** + * Used for pin drop location sharing. + **/ + const val PIN = "m.pin" } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt index d07bd2d73ae5df8f6f8ad1e853f11881cbd26636..2052133b068cf360d7da16ae60ae7fb4f758edce 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt @@ -39,37 +39,47 @@ data class MessageLocationContent( */ @Json(name = "geo_uri") val geoUri: String, - /** - * See https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md - */ - @Json(name = "org.matrix.msc3488.location") val locationInfo: LocationInfo? = null, - @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, - /** - * m.asset defines a generic asset that can be used for location tracking but also in other places like + * See [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md) + */ + @Json(name = "org.matrix.msc3488.location") val unstableLocationInfo: LocationInfo? = null, + @Json(name = "m.location") val locationInfo: LocationInfo? = null, + /** + * Exact time that the data in the event refers to (milliseconds since the UNIX epoch) + */ + @Json(name = "org.matrix.msc3488.ts") val unstableTs: Long? = null, + @Json(name = "m.ts") val ts: Long? = null, + @Json(name = "org.matrix.msc1767.text") val unstableText: String? = null, + @Json(name = "m.text") val text: String? = null, + /** + * Defines a generic asset that can be used for location tracking but also in other places like * inventories, geofencing, checkins/checkouts etc. * It should contain a mandatory namespaced type key defining what particular asset is being referred to. * For the purposes of user location tracking m.self should be used in order to avoid duplicating the mxid. + * See [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md) */ - @Json(name = "m.asset") val locationAsset: LocationAsset? = null, + @Json(name = "org.matrix.msc3488.asset") val unstableLocationAsset: LocationAsset? = null, + @Json(name = "m.asset") val locationAsset: LocationAsset? = null +) : MessageContent { - /** - * Exact time that the data in the event refers to (milliseconds since the UNIX epoch) - */ - @Json(name = "org.matrix.msc3488.ts") val ts: Long? = null, + fun getBestLocationInfo() = locationInfo ?: unstableLocationInfo - @Json(name = "org.matrix.msc1767.text") val text: String? = null -) : MessageContent { + fun getBestTs() = ts ?: unstableTs + + fun getBestText() = text ?: unstableText + + fun getBestLocationAsset() = locationAsset ?: unstableLocationAsset - fun getBestGeoUri() = locationInfo?.geoUri ?: geoUri + fun getBestGeoUri() = getBestLocationInfo()?.geoUri ?: geoUri /** * @return true if the location asset is a user location, not a generic one. */ fun isSelfLocation(): Boolean { // Should behave like m.self if locationAsset is null + val locationAsset = getBestLocationAsset() return locationAsset?.type == null || locationAsset.type == LocationAssetType.SELF } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt index a4e131729066227a6655dd9f364ee333660ec94a..43c0c9006818342e9c4f531936df68299eba1688 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt @@ -31,5 +31,9 @@ data class MessagePollContent( @Json(name = "body") override val body: String = "", @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, - @Json(name = "org.matrix.msc3381.poll.start") val pollCreationInfo: PollCreationInfo? = null -) : MessageContent + @Json(name = "org.matrix.msc3381.poll.start") val unstablePollCreationInfo: PollCreationInfo? = null, + @Json(name = "m.poll.start") val pollCreationInfo: PollCreationInfo? = null +) : MessageContent { + + fun getBestPollCreationInfo() = pollCreationInfo ?: unstablePollCreationInfo +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt index f3b4e3dc23c1d1e8f0b9b1cdce9e59ed7f0ee415..022915ed695d1a4cc1d88c4447f57333d0b436b3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt @@ -31,5 +31,9 @@ data class MessagePollResponseContent( @Json(name = "body") override val body: String = "", @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, - @Json(name = "org.matrix.msc3381.poll.response") val response: PollResponse? = null -) : MessageContent + @Json(name = "org.matrix.msc3381.poll.response") val unstableResponse: PollResponse? = null, + @Json(name = "m.response") val response: PollResponse? = null +) : MessageContent { + + fun getBestResponse() = response ?: unstableResponse +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt index 8f5ff53c85cf8329e996c7d400a895327563701f..34614d9d15d6b066a83740435bb74ef973ce07f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt @@ -22,5 +22,9 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class PollAnswer( @Json(name = "id") val id: String? = null, - @Json(name = "org.matrix.msc1767.text") val answer: String? = null -) + @Json(name = "org.matrix.msc1767.text") val unstableAnswer: String? = null, + @Json(name = "m.text") val answer: String? = null +) { + + fun getBestAnswer() = answer ?: unstableAnswer +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt index a82c01b15989bbd5425a3e89085ec18d108fd81a..81b034a809b3debb3dd2faf8d13b241b0ae36c6b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt @@ -21,8 +21,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class PollCreationInfo( - @Json(name = "question") val question: PollQuestion? = null, - @Json(name = "kind") val kind: PollType? = PollType.DISCLOSED, - @Json(name = "max_selections") val maxSelections: Int = 1, - @Json(name = "answers") val answers: List<PollAnswer>? = null + @Json(name = "question") val question: PollQuestion? = null, + @Json(name = "kind") val kind: PollType? = PollType.DISCLOSED_UNSTABLE, + @Json(name = "max_selections") val maxSelections: Int = 1, + @Json(name = "answers") val answers: List<PollAnswer>? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt index 76025f745e6346cac622dbca8a429c2a530ab8d3..df9517892b18e2c9ee11f969ab2aba16d7b4264d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt @@ -21,5 +21,9 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class PollQuestion( - @Json(name = "org.matrix.msc1767.text") val question: String? = null -) + @Json(name = "org.matrix.msc1767.text") val unstableQuestion: String? = null, + @Json(name = "m.text") val question: String? = null +) { + + fun getBestQuestion() = question ?: unstableQuestion +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt index 3a8066b9bc10caea1f2ff1893ee070eb360a149a..54801e698dbdb3a25e8f9c0e0461d614c92c654a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt @@ -25,11 +25,17 @@ enum class PollType { * Voters should see results as soon as they have voted. */ @Json(name = "org.matrix.msc3381.poll.disclosed") + DISCLOSED_UNSTABLE, + + @Json(name = "m.poll.disclosed") DISCLOSED, /** * Results should be only revealed when the poll is ended. */ @Json(name = "org.matrix.msc3381.poll.undisclosed") + UNDISCLOSED_UNSTABLE, + + @Json(name = "m.poll.undisclosed") UNDISCLOSED } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt index 733d6c37e86d977c997e6d48c9b58682c0979cef..e7bebeeff66afa5e429186f6658875f381ff58f8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt @@ -26,5 +26,6 @@ data class ReactionInfo( @Json(name = "key") val key: String, // always null for reaction @Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, - @Json(name = "option") override val option: Int? = null + @Json(name = "option") override val option: Int? = null, + @Json(name = "is_falling_back") override val isFallingBack: Boolean? = null ) : RelationContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt index e2080bb4376fb24dcd07bd7555d6330415ceb93e..53b1fea873d5b43dd936a7b88c74dd528e9246b0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt @@ -24,4 +24,10 @@ interface RelationContent { val eventId: String? val inReplyTo: ReplyToContent? val option: Int? + + /** + * This flag indicates that the message should be rendered as a reply + * fallback, when isFallingBack = false + */ + val isFallingBack: Boolean? } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt index 10b071a6013cc58a63fcea6e1e48d1db10ac05ec..5dcb1b4323bcf1d2edc803dafdae8cf0750d2971 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt @@ -23,5 +23,8 @@ data class RelationDefaultContent( @Json(name = "rel_type") override val type: String?, @Json(name = "event_id") override val eventId: String?, @Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, - @Json(name = "option") override val option: Int? = null + @Json(name = "option") override val option: Int? = null, + @Json(name = "is_falling_back") override val isFallingBack: Boolean? = null ) : RelationContent + +fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt index 09114436f04075cd9401717c4af0bd6a39e7961b..44098989084eb4d43435fc9f65960a93bd7ab0e4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt @@ -163,13 +163,4 @@ interface RelationService { autoMarkdown: Boolean = false, formattedText: String? = null, eventReplied: TimelineEvent? = null): Cancelable? - - /** - * Get all the thread replies for the specified rootThreadEventId - * The return list will contain the original root thread event and all the thread replies to that event - * Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready - * from the backend - * @param rootThreadEventId the root thread eventId - */ - suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt index 412a1bfca9dcec11ab88a5d97520cc8f4aecc974..251328bea210065be3c79c58633453f935899f78 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt @@ -21,8 +21,5 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ReplyToContent( - @Json(name = "event_id") val eventId: String? = null, - @Json(name = "render_in") val renderIn: List<String>? = null + @Json(name = "event_id") val eventId: String? = null ) - -fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 913dbfd01096adf5f93ffbaa3751e4fd705a6206..9f8b1d93d71f38f5a46c018132c371cfbef2879b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -142,8 +142,9 @@ interface SendService { * @param latitude required latitude of the location * @param longitude required longitude of the location * @param uncertainty Accuracy of the location in meters + * @param isUserLocation indicates whether the location data corresponds to the user location or not */ - fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable + fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable /** * Remove this failed message from the timeline diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt index 3bba2deae53019430464d9c80d81f1aad9186c7a..eaed9053eaed84a8f175022ff54ddacf32efd3de 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt @@ -32,7 +32,6 @@ object RoomSummaryConstants { EventType.CALL_ANSWER, EventType.ENCRYPTED, EventType.STICKER, - EventType.REACTION, - EventType.POLL_START - ) + EventType.REACTION + ) + EventType.POLL_START } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt index e4d1d979e1a8f8c61b55fa8afc5a99b58ee8a040..839cdff63badd9cb2052cb615e2ffec14f5d1875 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt @@ -17,51 +17,43 @@ package org.matrix.android.sdk.api.session.room.threads import androidx.lifecycle.LiveData -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary /** - * This interface defines methods to interact with threads related features. - * It's implemented at the room level within the main timeline. + * This interface defines methods to interact with thread related features. + * It's the dynamic threads implementation and the homeserver must return + * a capability entry for threads. If the server do not support m.thread + * then [ThreadsLocalService] should be used instead */ interface ThreadsService { /** - * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level + * Returns a [LiveData] list of all the [ThreadSummary] that exists at the room level */ - fun getAllThreadsLive(): LiveData<List<TimelineEvent>> + fun getAllThreadSummariesLive(): LiveData<List<ThreadSummary>> /** - * Returns a list of all the thread root TimelineEvents that exists at the room level + * Returns a list of all the [ThreadSummary] that exists at the room level */ - fun getAllThreads(): List<TimelineEvent> + fun getAllThreadSummaries(): List<ThreadSummary> /** - * Returns a [LiveData] list of all the marked unread threads that exists at the room level - */ - fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>> - - /** - * Returns a list of all the marked unread threads that exists at the room level - */ - fun getMarkedThreadNotifications(): List<TimelineEvent> - - /** - * Returns whether or not the current user is participating in the thread - * @param rootThreadEventId the eventId of the current thread + * Enhance the provided ThreadSummary[List] by adding the latest + * message edition for that thread + * @return the enhanced [List] with edited updates */ - fun isUserParticipatingInThread(rootThreadEventId: String): Boolean + fun enhanceThreadWithEditions(threads: List<ThreadSummary>): List<ThreadSummary> /** - * Enhance the provided root thread TimelineEvent [List] by adding the latest - * message edition for that thread - * @return the enhanced [List] with edited updates + * Fetch all thread replies for the specified thread using the /relations api + * @param rootThreadEventId the root thread eventId + * @param from defines the token that will fetch from that position + * @param limit defines the number of max results the api will respond with */ - fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> + suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int) /** - * Marks the current thread as read in local DB. - * note: read receipts within threads are not yet supported with the API - * @param rootThreadEventId the root eventId of the current thread + * Fetch all thread summaries for the current room using the enhanced /messages api */ - suspend fun markThreadAsRead(rootThreadEventId: String) + suspend fun fetchThreadSummaries() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt new file mode 100644 index 0000000000000000000000000000000000000000..f7b379e3821b04d1a920b993139b0a647c8a6757 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt @@ -0,0 +1,68 @@ +/* + * 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.api.session.room.threads.local + +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +/** + * This interface defines methods to interact with thread related features. + * It's the local threads implementation and assumes that the homeserver + * do not support threads + */ +interface ThreadsLocalService { + + /** + * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreadsLive(): LiveData<List<TimelineEvent>> + + /** + * Returns a list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreads(): List<TimelineEvent> + + /** + * Returns a [LiveData] list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>> + + /** + * Returns a list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotifications(): List<TimelineEvent> + + /** + * Returns whether or not the current user is participating in the thread + * @param rootThreadEventId the eventId of the current thread + */ + fun isUserParticipatingInThread(rootThreadEventId: String): Boolean + + /** + * Enhance the provided root thread TimelineEvent [List] by adding the latest + * message edition for that thread + * @return the enhanced [List] with edited updates + */ + fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> + + /** + * Marks the current thread as read in local DB. + * note: read receipts within threads are not yet supported with the API + * @param rootThreadEventId the root eventId of the current thread + */ + suspend fun markThreadAsRead(rootThreadEventId: String) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt new file mode 100644 index 0000000000000000000000000000000000000000..c8353cf0de0c7255aae7472e55b0eda99653eb3c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt @@ -0,0 +1,20 @@ +/* + * 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.api.session.room.threads.model + +data class ThreadEditions(var rootThreadEdition: String? = null, + var latestThreadEdition: String? = null) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt new file mode 100644 index 0000000000000000000000000000000000000000..017afba1baebee3b169a672542515b4ea55a81b8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt @@ -0,0 +1,33 @@ +/* + * 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.api.session.room.threads.model + +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.sender.SenderInfo + +/** + * The main thread Summary model, mainly used to display the thread list + */ +data class ThreadSummary(val roomId: String, + val rootEvent: Event?, + val latestEvent: Event?, + val rootEventId: String, + val rootThreadSenderInfo: SenderInfo, + val latestThreadSenderInfo: SenderInfo, + val isUserParticipating: Boolean, + val numberOfThreads: Int, + val threadEditions: ThreadEditions = ThreadEditions()) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Exhaustive.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt similarity index 68% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Exhaustive.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt index 097bdaf153069e06e7ac31ceb2a1707fe5f45149..95697f987f08b3c3fabfaebe10f77b5fae4e1b75 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Exhaustive.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt @@ -1,11 +1,11 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * 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 + * 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, @@ -14,7 +14,9 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.util +package org.matrix.android.sdk.api.session.room.threads.model -// Trick to ensure that when block is exhaustive -internal val <T> T.exhaustive: T get() = this +enum class ThreadSummaryUpdateType { + REPLACE, + ADD +} 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 6f8bae876bded065bf675a015ee7b44bc3d1dfc4..1b01efc074b4605a38fdf66a45afc7291209518b 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 @@ -17,6 +17,7 @@ package org.matrix.android.sdk.api.session.room.timeline import org.matrix.android.sdk.BuildConfig +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.RelationType @@ -54,6 +55,7 @@ data class TimelineEvent( * It's not unique on the timeline as it's reset on each chunk. */ val displayIndex: Int, + var ownedByThreadChunk: Boolean = false, val senderInfo: SenderInfo, val annotations: EventAnnotationsSummary? = null, val readReceipts: List<ReadReceipt> = emptyList() @@ -134,9 +136,9 @@ fun TimelineEvent.getEditedEventId(): String? { */ fun TimelineEvent.getLastMessageContent(): MessageContent? { return when (root.getClearType()) { - EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>() - EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>() - else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() + EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>() + in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>() + else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() } } @@ -158,6 +160,13 @@ fun TimelineEvent.isSticker(): Boolean { return root.isSticker() } +/** + * Returns whether or not the event is a root thread event + */ +fun TimelineEvent.isRootThread(): Boolean { + return root.threadDetails?.isRootThread.orFalse() +} + /** * Get the latest message body, after a possible edition, stripping the reply prefix if necessary */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt index 61520696445913f8dac2b508b3773e0357b5f5fe..46433f387d36b2297d3571c687feadb122a9e2c0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt @@ -38,7 +38,7 @@ interface TimelineService { /** * Returns a snapshot of TimelineEvent event with eventId. - * At the opposite of getTimeLineEventLive which will be updated when local echo event is synced, it will return null in this case. + * At the opposite of getTimelineEventLive which will be updated when local echo event is synced, it will return null in this case. * @param eventId the eventId to get the TimelineEvent */ fun getTimelineEvent(eventId: String): TimelineEvent? diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt index fafe17b2c0957261a1992858f89344d1e65b5dac..d6937d5b265b65cc564d85ca295b83a23ee78014 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.api.session.threads +import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.sender.SenderInfo /** @@ -26,7 +27,7 @@ data class ThreadDetails( val isRootThread: Boolean = false, val numberOfThreads: Int = 0, val threadSummarySenderInfo: SenderInfo? = null, - val threadSummaryLatestTextMessage: String? = null, + val threadSummaryLatestEvent: Event? = null, val lastMessageTimestamp: Long? = null, var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE, val isThread: Boolean = false, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index 302f7387fad7363c72988c1a307f33a13bb4f1f8..650b8cc26db1a5ab7c22f1b72159b1c69564bc65 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.api.util import org.matrix.android.sdk.BuildConfig +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.group.model.GroupSummary import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -199,6 +200,8 @@ fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName, fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) +fun SenderInfo.toMatrixItemOrNull() = tryOrNull { MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) } + fun SpaceChildInfo.toMatrixItem() = if (roomType == RoomType.SPACE) { MatrixItem.SpaceItem(childRoomId, name ?: canonicalAlias, avatarUrl) } else { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt index 0a9b8b73cc1c5084f7beb44a78998eb744dc3ab7..815f8de2de28b80c6a1067f943b2e67b3e21395c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt @@ -38,7 +38,7 @@ internal data class HomeServerVersion( } companion object { - internal val pattern = Regex("""r(\d+)\.(\d+)\.(\d+)""") + internal val pattern = Regex("""[r|v](\d+)\.(\d+)\.(\d+)""") internal fun parse(value: String): HomeServerVersion? { val result = pattern.matchEntire(value) ?: return null @@ -56,5 +56,6 @@ internal data class HomeServerVersion( val r0_4_0 = HomeServerVersion(major = 0, minor = 4, patch = 0) val r0_5_0 = HomeServerVersion(major = 0, minor = 5, patch = 0) val r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0) + val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt index 74cb3de2acf5bddf4af03aedfd8da6398eaca6ef..d07d5ecd64b62ba4eef581183557a9d98404f7bf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt @@ -51,6 +51,8 @@ private const val FEATURE_LAZY_LOAD_MEMBERS = "m.lazy_load_members" private const val FEATURE_REQUIRE_IDENTITY_SERVER = "m.require_identity_server" private const val FEATURE_ID_ACCESS_TOKEN = "m.id_access_token" private const val FEATURE_SEPARATE_ADD_AND_BIND = "m.separate_add_and_bind" +private const val FEATURE_THREADS_MSC3440 = "org.matrix.msc3440" +private const val FEATURE_THREADS_MSC3440_STABLE = "org.matrix.msc3440.stable" /** * Return true if the SDK supports this homeserver version @@ -68,6 +70,14 @@ internal fun Versions.isLoginAndRegistrationSupportedBySdk(): Boolean { doesServerSeparatesAddAndBind() } +/** + * Indicate if the homeserver support MSC3440 for threads + */ +internal fun Versions.doesServerSupportThreads(): Boolean { + return getMaxVersion() >= HomeServerVersion.v1_3_0 || + unstableFeatures?.get(FEATURE_THREADS_MSC3440_STABLE) ?: false +} + /** * Return true if the server support the lazy loading of room members * diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt index 3130a6382f1f8dd0346831f87bb18bd57a9d9d4f..2265526484bd5b734ea2cedeb5c111c953b3b3cc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt @@ -137,8 +137,7 @@ internal abstract class CryptoModule { @JvmStatic @Provides @CryptoDatabase - fun providesClearCacheTask(@CryptoDatabase - realmConfiguration: RealmConfiguration): ClearCacheTask { + fun providesClearCacheTask(@CryptoDatabase realmConfiguration: RealmConfiguration): ClearCacheTask { return RealmClearCacheTask(realmConfiguration) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt index 0646e4d2b8c7e72925209d502fce6c68d1c4add2..db44abc36ff4279bfeb5c951b39a5fb82f511619 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt @@ -434,6 +434,14 @@ internal class DefaultCryptoService @Inject constructor( val currentCount = syncResponse.deviceOneTimeKeysCount.signedCurve25519 ?: 0 oneTimeKeysUploader.updateOneTimeKeyCount(currentCount) } + + // unwedge if needed + try { + eventDecryptor.unwedgeDevicesIfNeeded() + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).w("unwedgeDevicesIfNeeded failed") + } + // There is a limit of to_device events returned per sync. // If we are in a case of such limited to_device sync we can't try to generate/upload // new otk now, because there might be some pending olm pre-key to_device messages that would fail if we rotate @@ -723,7 +731,7 @@ internal class DefaultCryptoService @Inject constructor( * @return the MXEventDecryptionResult data, or throw in case of error */ @Throws(MXCryptoError::class) - override fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { + override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { return internalDecryptEvent(event, timeline) } @@ -746,7 +754,7 @@ internal class DefaultCryptoService @Inject constructor( * @return the MXEventDecryptionResult data, or null in case of error */ @Throws(MXCryptoError::class) - private fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult { + private suspend fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult { return eventDecryptor.decryptEvent(event, timeline) } @@ -1364,6 +1372,9 @@ internal class DefaultCryptoService @Inject constructor( @VisibleForTesting val cryptoStoreForTesting = cryptoStore + @VisibleForTesting + val olmDeviceForTest = olmDevice + companion object { const val CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS = 3_600_000 // one hour } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt index 57381eacfb47954160d19e3e4b8e05618fb85f72..00efd3d6a854ceb5c80815f92ea8fe64aa577208 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt @@ -21,14 +21,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCoroutineDispatchers +import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.Event 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.internal.crypto.actions.EnsureOlmSessionsForDevicesAction import org.matrix.android.sdk.internal.crypto.actions.MessageEncrypter -import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo -import org.matrix.android.sdk.internal.crypto.model.MXOlmSessionResult import org.matrix.android.sdk.internal.crypto.model.MXUsersDevicesMap import org.matrix.android.sdk.internal.crypto.model.event.OlmEventContent import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore @@ -40,6 +39,8 @@ import javax.inject.Inject private const val SEND_TO_DEVICE_RETRY_COUNT = 3 +private val loggerTag = LoggerTag("CryptoSyncHandler", LoggerTag.CRYPTO) + @SessionScope internal class EventDecryptor @Inject constructor( private val cryptoCoroutineScope: CoroutineScope, @@ -47,13 +48,22 @@ internal class EventDecryptor @Inject constructor( private val roomDecryptorProvider: RoomDecryptorProvider, private val messageEncrypter: MessageEncrypter, private val sendToDeviceTask: SendToDeviceTask, + private val deviceListManager: DeviceListManager, private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, private val cryptoStore: IMXCryptoStore ) { - // The date of the last time we forced establishment - // of a new session for each user:device. - private val lastNewSessionForcedDates = MXUsersDevicesMap<Long>() + /** + * Rate limit unwedge attempt, should we persist that? + */ + private val lastNewSessionForcedDates = mutableMapOf<WedgedDeviceInfo, Long>() + + data class WedgedDeviceInfo( + val userId: String, + val senderKey: String? + ) + + private val wedgedDevices = mutableListOf<WedgedDeviceInfo>() /** * Decrypt an event @@ -63,7 +73,7 @@ internal class EventDecryptor @Inject constructor( * @return the MXEventDecryptionResult data, or throw in case of error */ @Throws(MXCryptoError::class) - fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { + suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { return internalDecryptEvent(event, timeline) } @@ -91,38 +101,32 @@ internal class EventDecryptor @Inject constructor( * @return the MXEventDecryptionResult data, or null in case of error */ @Throws(MXCryptoError::class) - private fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult { + private suspend fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult { val eventContent = event.content if (eventContent == null) { - Timber.e("## CRYPTO | decryptEvent : empty event content") + Timber.tag(loggerTag.value).e("decryptEvent : empty event content") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE, MXCryptoError.BAD_ENCRYPTED_MESSAGE_REASON) } else { val algorithm = eventContent["algorithm"]?.toString() val alg = roomDecryptorProvider.getOrCreateRoomDecryptor(event.roomId, algorithm) if (alg == null) { val reason = String.format(MXCryptoError.UNABLE_TO_DECRYPT_REASON, event.eventId, algorithm) - Timber.e("## CRYPTO | decryptEvent() : $reason") + Timber.tag(loggerTag.value).e("decryptEvent() : $reason") throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, reason) } else { try { return alg.decryptEvent(event, timeline) } catch (mxCryptoError: MXCryptoError) { - Timber.v("## CRYPTO | internalDecryptEvent : Failed to decrypt ${event.eventId} reason: $mxCryptoError") + Timber.tag(loggerTag.value).d("internalDecryptEvent : Failed to decrypt ${event.eventId} reason: $mxCryptoError") if (algorithm == MXCRYPTO_ALGORITHM_OLM) { if (mxCryptoError is MXCryptoError.Base && mxCryptoError.errorType == MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE) { // need to find sending device - cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { - val olmContent = event.content.toModel<OlmEventContent>() - cryptoStore.getUserDevices(event.senderId ?: "") - ?.values - ?.firstOrNull { it.identityKey() == olmContent?.senderKey } - ?.let { - markOlmSessionForUnwedging(event.senderId ?: "", it) - } - ?: run { - Timber.i("## CRYPTO | internalDecryptEvent() : Failed to find sender crypto device for unwedging") - } + val olmContent = event.content.toModel<OlmEventContent>() + if (event.senderId != null && olmContent?.senderKey != null) { + markOlmSessionForUnwedging(event.senderId, olmContent.senderKey) + } else { + Timber.tag(loggerTag.value).d("Can't mark as wedge malformed") } } } @@ -132,53 +136,91 @@ internal class EventDecryptor @Inject constructor( } } - // coroutineDispatchers.crypto scope - private fun markOlmSessionForUnwedging(senderId: String, deviceInfo: CryptoDeviceInfo) { - val deviceKey = deviceInfo.identityKey() + private fun markOlmSessionForUnwedging(senderId: String, senderKey: String) { + val info = WedgedDeviceInfo(senderId, senderKey) + if (!wedgedDevices.contains(info)) { + Timber.tag(loggerTag.value).d("Marking device from $senderId key:$senderKey as wedged") + wedgedDevices.add(info) + } + } - val lastForcedDate = lastNewSessionForcedDates.getObject(senderId, deviceKey) ?: 0 + // coroutineDispatchers.crypto scope + suspend fun unwedgeDevicesIfNeeded() { + // handle wedged devices + // Some olm decryption have failed and some device are wedged + // we should force start a new session for those + Timber.tag(loggerTag.value).v("Unwedging: ${wedgedDevices.size} are wedged") + // get the one that should be retried according to rate limit val now = System.currentTimeMillis() - if (now - lastForcedDate < DefaultCryptoService.CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS) { - Timber.w("## CRYPTO | markOlmSessionForUnwedging: New session already forced with device at $lastForcedDate. Not forcing another") - return + val toUnwedge = wedgedDevices.filter { + val lastForcedDate = lastNewSessionForcedDates[it] ?: 0 + if (now - lastForcedDate < DefaultCryptoService.CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS) { + Timber.tag(loggerTag.value).d("Unwedging, New session for $it already forced with device at $lastForcedDate") + return@filter false + } + // let's already mark that we tried now + lastNewSessionForcedDates[it] = now + true } - Timber.i("## CRYPTO | markOlmSessionForUnwedging from $senderId:${deviceInfo.deviceId}") - lastNewSessionForcedDates.setObject(senderId, deviceKey, now) + if (toUnwedge.isEmpty()) { + Timber.tag(loggerTag.value).v("Nothing to unwedge") + return + } + Timber.tag(loggerTag.value).d("Unwedging, trying to create new session for ${toUnwedge.size} devices") - // offload this from crypto thread (?) - cryptoCoroutineScope.launch(coroutineDispatchers.computation) { - runCatching { ensureOlmSessionsForDevicesAction.handle(mapOf(senderId to listOf(deviceInfo)), force = true) }.fold( - onSuccess = { sendDummyToDevice(ensured = it, deviceInfo, senderId) }, - onFailure = { - Timber.e("## CRYPTO | markOlmSessionForUnwedging() : failed to ensure device info ${senderId}${deviceInfo.deviceId}") + toUnwedge + .chunked(100) // safer to chunk if we ever have lots of wedged devices + .forEach { wedgedList -> + val groupedByUserId = wedgedList.groupBy { it.userId } + // lets download keys if needed + withContext(coroutineDispatchers.io) { + deviceListManager.downloadKeys(groupedByUserId.keys.toList(), false) } - ) - } - } - private suspend fun sendDummyToDevice(ensured: MXUsersDevicesMap<MXOlmSessionResult>, deviceInfo: CryptoDeviceInfo, senderId: String) { - Timber.i("## CRYPTO | markOlmSessionForUnwedging() : ensureOlmSessionsForDevicesAction isEmpty:${ensured.isEmpty}") - - // Now send a blank message on that session so the other side knows about it. - // (The keyshare request is sent in the clear so that won't do) - // We send this first such that, as long as the toDevice messages arrive in the - // same order we sent them, the other end will get this first, set up the new session, - // then get the keyshare request and send the key over this new session (because it - // is the session it has most recently received a message on). - val payloadJson = mapOf<String, Any>("type" to EventType.DUMMY) - - val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo)) - val sendToDeviceMap = MXUsersDevicesMap<Any>() - sendToDeviceMap.setObject(senderId, deviceInfo.deviceId, encodedPayload) - Timber.i("## CRYPTO | markOlmSessionForUnwedging() : sending dummy to $senderId:${deviceInfo.deviceId}") - withContext(coroutineDispatchers.io) { - val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap) - try { - sendToDeviceTask.executeRetry(sendToDeviceParams, remainingRetry = SEND_TO_DEVICE_RETRY_COUNT) - } catch (failure: Throwable) { - Timber.e(failure, "## CRYPTO | markOlmSessionForUnwedging() : failed to send dummy to $senderId:${deviceInfo.deviceId}") - } - } + // find the matching devices + groupedByUserId + .map { groupedByUser -> + val userId = groupedByUser.key + val wedgeSenderKeysForUser = groupedByUser.value.map { it.senderKey } + val knownDevices = cryptoStore.getUserDevices(userId)?.values.orEmpty() + userId to wedgeSenderKeysForUser.mapNotNull { senderKey -> + knownDevices.firstOrNull { it.identityKey() == senderKey } + } + } + .toMap() + .let { deviceList -> + try { + // force creating new outbound session and mark them as most recent to + // be used for next encryption (dummy) + val sessionToUse = ensureOlmSessionsForDevicesAction.handle(deviceList, true) + Timber.tag(loggerTag.value).d("Unwedging, found ${sessionToUse.map.size} to send dummy to") + + // Now send a dummy message on that session so the other side knows about it. + val payloadJson = mapOf( + "type" to EventType.DUMMY + ) + val sendToDeviceMap = MXUsersDevicesMap<Any>() + sessionToUse.map.values + .flatMap { it.values } + .map { it.deviceInfo } + .forEach { deviceInfo -> + Timber.tag(loggerTag.value).v("encrypting dummy to ${deviceInfo.deviceId}") + val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo)) + sendToDeviceMap.setObject(deviceInfo.userId, deviceInfo.deviceId, encodedPayload) + } + + // now let's send that + val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap) + withContext(coroutineDispatchers.io) { + sendToDeviceTask.executeRetry(sendToDeviceParams, remainingRetry = SEND_TO_DEVICE_RETRY_COUNT) + } + } catch (failure: Throwable) { + deviceList.flatMap { it.value }.joinToString { it.shortDebugString() }.let { + Timber.tag(loggerTag.value).e(failure, "## Failed to unwedge devices: $it}") + } + } + } + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt index e7a46750b0a84afad9c7b07a3a26a37d5a349a22..34bef61c9866d6eb9806770e3490e443d6ac4754 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt @@ -19,8 +19,10 @@ package org.matrix.android.sdk.internal.crypto import android.util.LruCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import timber.log.Timber @@ -28,6 +30,13 @@ import java.util.Timer import java.util.TimerTask import javax.inject.Inject +data class InboundGroupSessionHolder( + val wrapper: OlmInboundGroupSessionWrapper2, + val mutex: Mutex = Mutex() +) + +private val loggerTag = LoggerTag("InboundGroupSessionStore", LoggerTag.CRYPTO) + /** * Allows to cache and batch store operations on inbound group session store. * Because it is used in the decrypt flow, that can be called quite rapidly @@ -42,12 +51,13 @@ internal class InboundGroupSessionStore @Inject constructor( val senderKey: String ) - private val sessionCache = object : LruCache<CacheKey, OlmInboundGroupSessionWrapper2>(30) { - override fun entryRemoved(evicted: Boolean, key: CacheKey?, oldValue: OlmInboundGroupSessionWrapper2?, newValue: OlmInboundGroupSessionWrapper2?) { - if (evicted && oldValue != null) { + private val sessionCache = object : LruCache<CacheKey, InboundGroupSessionHolder>(100) { + override fun entryRemoved(evicted: Boolean, key: CacheKey?, oldValue: InboundGroupSessionHolder?, newValue: InboundGroupSessionHolder?) { + if (oldValue != null) { cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { - Timber.v("## Inbound: entryRemoved ${oldValue.roomId}-${oldValue.senderKey}") - store.storeInboundGroupSessions(listOf(oldValue)) + Timber.tag(loggerTag.value).v("## Inbound: entryRemoved ${oldValue.wrapper.roomId}-${oldValue.wrapper.senderKey}") + store.storeInboundGroupSessions(listOf(oldValue).map { it.wrapper }) + oldValue.wrapper.olmInboundGroupSession?.releaseSession() } } } @@ -59,27 +69,50 @@ internal class InboundGroupSessionStore @Inject constructor( private val dirtySession = mutableListOf<OlmInboundGroupSessionWrapper2>() @Synchronized - fun getInboundGroupSession(sessionId: String, senderKey: String): OlmInboundGroupSessionWrapper2? { - synchronized(sessionCache) { + fun clear() { + sessionCache.evictAll() + } + + @Synchronized + fun getInboundGroupSession(sessionId: String, senderKey: String): InboundGroupSessionHolder? { val known = sessionCache[CacheKey(sessionId, senderKey)] - Timber.v("## Inbound: getInboundGroupSession in cache ${known != null}") - return known ?: store.getInboundGroupSession(sessionId, senderKey)?.also { - Timber.v("## Inbound: getInboundGroupSession cache populate ${it.roomId}") - sessionCache.put(CacheKey(sessionId, senderKey), it) + Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession $sessionId in cache ${known != null}") + return known + ?: store.getInboundGroupSession(sessionId, senderKey)?.also { + Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession cache populate ${it.roomId}") + sessionCache.put(CacheKey(sessionId, senderKey), InboundGroupSessionHolder(it)) + }?.let { + InboundGroupSessionHolder(it) } - } } @Synchronized - fun storeInBoundGroupSession(wrapper: OlmInboundGroupSessionWrapper2, sessionId: String, senderKey: String) { - Timber.v("## Inbound: getInboundGroupSession mark as dirty ${wrapper.roomId}-${wrapper.senderKey}") + fun replaceGroupSession(old: InboundGroupSessionHolder, new: InboundGroupSessionHolder, sessionId: String, senderKey: String) { + Timber.tag(loggerTag.value).v("## Replacing outdated session ${old.wrapper.roomId}-${old.wrapper.senderKey}") + dirtySession.remove(old.wrapper) + store.removeInboundGroupSession(sessionId, senderKey) + sessionCache.remove(CacheKey(sessionId, senderKey)) + + // release removed session + old.wrapper.olmInboundGroupSession?.releaseSession() + + internalStoreGroupSession(new, sessionId, senderKey) + } + + @Synchronized + fun storeInBoundGroupSession(holder: InboundGroupSessionHolder, sessionId: String, senderKey: String) { + internalStoreGroupSession(holder, sessionId, senderKey) + } + + private fun internalStoreGroupSession(holder: InboundGroupSessionHolder, sessionId: String, senderKey: String) { + Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession mark as dirty ${holder.wrapper.roomId}-${holder.wrapper.senderKey}") // We want to batch this a bit for performances - dirtySession.add(wrapper) + dirtySession.add(holder.wrapper) if (sessionCache[CacheKey(sessionId, senderKey)] == null) { // first time seen, put it in memory cache while waiting for batch insert // If it's already known, no need to update cache it's already there - sessionCache.put(CacheKey(sessionId, senderKey), wrapper) + sessionCache.put(CacheKey(sessionId, senderKey), holder) } timerTask?.cancel() @@ -96,7 +129,7 @@ internal class InboundGroupSessionStore @Inject constructor( val toSave = mutableListOf<OlmInboundGroupSessionWrapper2>().apply { addAll(dirtySession) } dirtySession.clear() cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { - Timber.v("## Inbound: getInboundGroupSession batching save of ${dirtySession.size}") + Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession batching save of ${toSave.size}") tryOrNull { store.storeInboundGroupSessions(toSave) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt index e1a706df799a971f1d161eb5ba78da9408996902..501fb42db2dacf15b93edd198d047a8ae2af74f9 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt @@ -16,6 +16,11 @@ package org.matrix.android.sdk.internal.crypto +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.util.JSON_DICT_PARAMETERIZED_TYPE import org.matrix.android.sdk.api.util.JsonDict @@ -40,6 +45,8 @@ import timber.log.Timber import java.net.URLEncoder import javax.inject.Inject +private val loggerTag = LoggerTag("MXOlmDevice", LoggerTag.CRYPTO) + // The libolm wrapper. @SessionScope internal class MXOlmDevice @Inject constructor( @@ -47,9 +54,12 @@ internal class MXOlmDevice @Inject constructor( * The store where crypto data is saved. */ private val store: IMXCryptoStore, + private val olmSessionStore: OlmSessionStore, private val inboundGroupSessionStore: InboundGroupSessionStore ) { + val mutex = Mutex() + /** * @return the Curve25519 key for the account. */ @@ -93,26 +103,26 @@ internal class MXOlmDevice @Inject constructor( try { store.getOrCreateOlmAccount() } catch (e: Exception) { - Timber.e(e, "MXOlmDevice : cannot initialize olmAccount") + Timber.tag(loggerTag.value).e(e, "MXOlmDevice : cannot initialize olmAccount") } try { olmUtility = OlmUtility() } catch (e: Exception) { - Timber.e(e, "## MXOlmDevice : OlmUtility failed with error") + Timber.tag(loggerTag.value).e(e, "## MXOlmDevice : OlmUtility failed with error") olmUtility = null } try { - deviceCurve25519Key = store.getOlmAccount().identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY] + deviceCurve25519Key = store.doWithOlmAccount { it.identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY] } } catch (e: Exception) { - Timber.e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_IDENTITY_KEY} with error") + Timber.tag(loggerTag.value).e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_IDENTITY_KEY} with error") } try { - deviceEd25519Key = store.getOlmAccount().identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY] + deviceEd25519Key = store.doWithOlmAccount { it.identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY] } } catch (e: Exception) { - Timber.e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_FINGER_PRINT_KEY} with error") + Timber.tag(loggerTag.value).e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_FINGER_PRINT_KEY} with error") } } @@ -121,9 +131,9 @@ internal class MXOlmDevice @Inject constructor( */ fun getOneTimeKeys(): Map<String, Map<String, String>>? { try { - return store.getOlmAccount().oneTimeKeys() + return store.doWithOlmAccount { it.oneTimeKeys() } } catch (e: Exception) { - Timber.e(e, "## getOneTimeKeys() : failed") + Timber.tag(loggerTag.value).e(e, "## getOneTimeKeys() : failed") } return null @@ -133,7 +143,7 @@ internal class MXOlmDevice @Inject constructor( * @return The maximum number of one-time keys the olm account can store. */ fun getMaxNumberOfOneTimeKeys(): Long { - return store.getOlmAccount().maxOneTimeKeys() + return store.doWithOlmAccount { it.maxOneTimeKeys() } } /** @@ -143,9 +153,9 @@ internal class MXOlmDevice @Inject constructor( */ fun getFallbackKey(): MutableMap<String, MutableMap<String, String>>? { try { - return store.getOlmAccount().fallbackKey() + return store.doWithOlmAccount { it.fallbackKey() } } catch (e: Exception) { - Timber.e("## getFallbackKey() : failed") + Timber.tag(loggerTag.value).e("## getFallbackKey() : failed") } return null } @@ -158,12 +168,14 @@ internal class MXOlmDevice @Inject constructor( fun generateFallbackKeyIfNeeded(): Boolean { try { if (!hasUnpublishedFallbackKey()) { - store.getOlmAccount().generateFallbackKey() - store.saveOlmAccount() + store.doWithOlmAccount { + it.generateFallbackKey() + store.saveOlmAccount() + } return true } } catch (e: Exception) { - Timber.e("## generateFallbackKey() : failed") + Timber.tag(loggerTag.value).e("## generateFallbackKey() : failed") } return false } @@ -174,10 +186,12 @@ internal class MXOlmDevice @Inject constructor( fun forgetFallbackKey() { try { - store.getOlmAccount().forgetFallbackKey() - store.saveOlmAccount() + store.doWithOlmAccount { + it.forgetFallbackKey() + store.saveOlmAccount() + } } catch (e: Exception) { - Timber.e("## forgetFallbackKey() : failed") + Timber.tag(loggerTag.value).e("## forgetFallbackKey() : failed") } } @@ -190,6 +204,8 @@ internal class MXOlmDevice @Inject constructor( it.groupSession.releaseSession() } outboundGroupSessionCache.clear() + inboundGroupSessionStore.clear() + olmSessionStore.clear() } /** @@ -200,9 +216,9 @@ internal class MXOlmDevice @Inject constructor( */ fun signMessage(message: String): String? { try { - return store.getOlmAccount().signMessage(message) + return store.doWithOlmAccount { it.signMessage(message) } } catch (e: Exception) { - Timber.e(e, "## signMessage() : failed") + Timber.tag(loggerTag.value).e(e, "## signMessage() : failed") } return null @@ -213,10 +229,12 @@ internal class MXOlmDevice @Inject constructor( */ fun markKeysAsPublished() { try { - store.getOlmAccount().markOneTimeKeysAsPublished() - store.saveOlmAccount() + store.doWithOlmAccount { + it.markOneTimeKeysAsPublished() + store.saveOlmAccount() + } } catch (e: Exception) { - Timber.e(e, "## markKeysAsPublished() : failed") + Timber.tag(loggerTag.value).e(e, "## markKeysAsPublished() : failed") } } @@ -227,10 +245,12 @@ internal class MXOlmDevice @Inject constructor( */ fun generateOneTimeKeys(numKeys: Int) { try { - store.getOlmAccount().generateOneTimeKeys(numKeys) - store.saveOlmAccount() + store.doWithOlmAccount { + it.generateOneTimeKeys(numKeys) + store.saveOlmAccount() + } } catch (e: Exception) { - Timber.e(e, "## generateOneTimeKeys() : failed") + Timber.tag(loggerTag.value).e(e, "## generateOneTimeKeys() : failed") } } @@ -243,12 +263,14 @@ internal class MXOlmDevice @Inject constructor( * @return the session id for the outbound session. */ fun createOutboundSession(theirIdentityKey: String, theirOneTimeKey: String): String? { - Timber.v("## createOutboundSession() ; theirIdentityKey $theirIdentityKey theirOneTimeKey $theirOneTimeKey") + Timber.tag(loggerTag.value).d("## createOutboundSession() ; theirIdentityKey $theirIdentityKey theirOneTimeKey $theirOneTimeKey") var olmSession: OlmSession? = null try { olmSession = OlmSession() - olmSession.initOutboundSession(store.getOlmAccount(), theirIdentityKey, theirOneTimeKey) + store.doWithOlmAccount { olmAccount -> + olmSession.initOutboundSession(olmAccount, theirIdentityKey, theirOneTimeKey) + } val olmSessionWrapper = OlmSessionWrapper(olmSession, 0) @@ -257,14 +279,14 @@ internal class MXOlmDevice @Inject constructor( // this session olmSessionWrapper.onMessageReceived() - store.storeSession(olmSessionWrapper, theirIdentityKey) + olmSessionStore.storeSession(olmSessionWrapper, theirIdentityKey) val sessionIdentifier = olmSession.sessionIdentifier() - Timber.v("## createOutboundSession() ; olmSession.sessionIdentifier: $sessionIdentifier") + Timber.tag(loggerTag.value).v("## createOutboundSession() ; olmSession.sessionIdentifier: $sessionIdentifier") return sessionIdentifier } catch (e: Exception) { - Timber.e(e, "## createOutboundSession() failed") + Timber.tag(loggerTag.value).e(e, "## createOutboundSession() failed") olmSession?.releaseSession() } @@ -281,34 +303,38 @@ internal class MXOlmDevice @Inject constructor( * @return {{payload: string, session_id: string}} decrypted payload, and session id of new session. */ fun createInboundSession(theirDeviceIdentityKey: String, messageType: Int, ciphertext: String): Map<String, String>? { - Timber.v("## createInboundSession() : theirIdentityKey: $theirDeviceIdentityKey") + Timber.tag(loggerTag.value).d("## createInboundSession() : theirIdentityKey: $theirDeviceIdentityKey") var olmSession: OlmSession? = null try { try { olmSession = OlmSession() - olmSession.initInboundSessionFrom(store.getOlmAccount(), theirDeviceIdentityKey, ciphertext) + store.doWithOlmAccount { olmAccount -> + olmSession.initInboundSessionFrom(olmAccount, theirDeviceIdentityKey, ciphertext) + } } catch (e: Exception) { - Timber.e(e, "## createInboundSession() : the session creation failed") + Timber.tag(loggerTag.value).e(e, "## createInboundSession() : the session creation failed") return null } - Timber.v("## createInboundSession() : sessionId: ${olmSession.sessionIdentifier()}") + Timber.tag(loggerTag.value).v("## createInboundSession() : sessionId: ${olmSession.sessionIdentifier()}") try { - store.getOlmAccount().removeOneTimeKeys(olmSession) - store.saveOlmAccount() + store.doWithOlmAccount { olmAccount -> + olmAccount.removeOneTimeKeys(olmSession) + store.saveOlmAccount() + } } catch (e: Exception) { - Timber.e(e, "## createInboundSession() : removeOneTimeKeys failed") + Timber.tag(loggerTag.value).e(e, "## createInboundSession() : removeOneTimeKeys failed") } - Timber.v("## createInboundSession() : ciphertext: $ciphertext") + Timber.tag(loggerTag.value).v("## createInboundSession() : ciphertext: $ciphertext") try { val sha256 = olmUtility!!.sha256(URLEncoder.encode(ciphertext, "utf-8")) - Timber.v("## createInboundSession() :ciphertext: SHA256: $sha256") + Timber.tag(loggerTag.value).v("## createInboundSession() :ciphertext: SHA256: $sha256") } catch (e: Exception) { - Timber.e(e, "## createInboundSession() :ciphertext: cannot encode ciphertext") + Timber.tag(loggerTag.value).e(e, "## createInboundSession() :ciphertext: cannot encode ciphertext") } val olmMessage = OlmMessage() @@ -324,9 +350,9 @@ internal class MXOlmDevice @Inject constructor( // This counts as a received message: set last received message time to now olmSessionWrapper.onMessageReceived() - store.storeSession(olmSessionWrapper, theirDeviceIdentityKey) + olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey) } catch (e: Exception) { - Timber.e(e, "## createInboundSession() : decryptMessage failed") + Timber.tag(loggerTag.value).e(e, "## createInboundSession() : decryptMessage failed") } val res = HashMap<String, String>() @@ -343,7 +369,7 @@ internal class MXOlmDevice @Inject constructor( return res } catch (e: Exception) { - Timber.e(e, "## createInboundSession() : OlmSession creation failed") + Timber.tag(loggerTag.value).e(e, "## createInboundSession() : OlmSession creation failed") olmSession?.releaseSession() } @@ -357,8 +383,8 @@ internal class MXOlmDevice @Inject constructor( * @param theirDeviceIdentityKey the Curve25519 identity key for the remote device. * @return a list of known session ids for the device. */ - fun getSessionIds(theirDeviceIdentityKey: String): List<String>? { - return store.getDeviceSessionIds(theirDeviceIdentityKey) + fun getSessionIds(theirDeviceIdentityKey: String): List<String> { + return olmSessionStore.getDeviceSessionIds(theirDeviceIdentityKey) } /** @@ -368,7 +394,7 @@ internal class MXOlmDevice @Inject constructor( * @return the session id, or null if no established session. */ fun getSessionId(theirDeviceIdentityKey: String): String? { - return store.getLastUsedSessionId(theirDeviceIdentityKey) + return olmSessionStore.getLastUsedSessionId(theirDeviceIdentityKey) } /** @@ -379,30 +405,30 @@ internal class MXOlmDevice @Inject constructor( * @param payloadString the payload to be encrypted and sent * @return the cipher text */ - fun encryptMessage(theirDeviceIdentityKey: String, sessionId: String, payloadString: String): Map<String, Any>? { - var res: MutableMap<String, Any>? = null - val olmMessage: OlmMessage + suspend fun encryptMessage(theirDeviceIdentityKey: String, sessionId: String, payloadString: String): Map<String, Any>? { val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId) if (olmSessionWrapper != null) { try { - Timber.v("## encryptMessage() : olmSession.sessionIdentifier: $sessionId") - // Timber.v("## encryptMessage() : payloadString: " + payloadString); - - olmMessage = olmSessionWrapper.olmSession.encryptMessage(payloadString) - store.storeSession(olmSessionWrapper, theirDeviceIdentityKey) - res = HashMap() + Timber.tag(loggerTag.value).v("## encryptMessage() : olmSession.sessionIdentifier: $sessionId") - res["body"] = olmMessage.mCipherText - res["type"] = olmMessage.mType - } catch (e: Exception) { - Timber.e(e, "## encryptMessage() : failed") + val olmMessage = olmSessionWrapper.mutex.withLock { + olmSessionWrapper.olmSession.encryptMessage(payloadString) + } + return mapOf( + "body" to olmMessage.mCipherText, + "type" to olmMessage.mType, + ).also { + olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey) + } + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## encryptMessage() : failed to encrypt olm with device|session:$theirDeviceIdentityKey|$sessionId") + return null } } else { - Timber.e("## encryptMessage() : Failed to encrypt unknown session $sessionId") + Timber.tag(loggerTag.value).e("## encryptMessage() : Failed to encrypt unknown session $sessionId") + return null } - - return res } /** @@ -414,7 +440,8 @@ internal class MXOlmDevice @Inject constructor( * @param sessionId the id of the active session. * @return the decrypted payload. */ - fun decryptMessage(ciphertext: String, messageType: Int, sessionId: String, theirDeviceIdentityKey: String): String? { + @kotlin.jvm.Throws + suspend fun decryptMessage(ciphertext: String, messageType: Int, sessionId: String, theirDeviceIdentityKey: String): String? { var payloadString: String? = null val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId) @@ -424,13 +451,13 @@ internal class MXOlmDevice @Inject constructor( olmMessage.mCipherText = ciphertext olmMessage.mType = messageType.toLong() - try { - payloadString = olmSessionWrapper.olmSession.decryptMessage(olmMessage) - olmSessionWrapper.onMessageReceived() - store.storeSession(olmSessionWrapper, theirDeviceIdentityKey) - } catch (e: Exception) { - Timber.e(e, "## decryptMessage() : decryptMessage failed") - } + payloadString = + olmSessionWrapper.mutex.withLock { + olmSessionWrapper.olmSession.decryptMessage(olmMessage).also { + olmSessionWrapper.onMessageReceived() + } + } + olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey) } return payloadString @@ -469,7 +496,7 @@ internal class MXOlmDevice @Inject constructor( store.storeCurrentOutboundGroupSessionForRoom(roomId, session) return session.sessionIdentifier() } catch (e: Exception) { - Timber.e(e, "createOutboundGroupSession") + Timber.tag(loggerTag.value).e(e, "createOutboundGroupSession") session?.releaseSession() } @@ -521,7 +548,7 @@ internal class MXOlmDevice @Inject constructor( try { return outboundGroupSessionCache[sessionId]!!.groupSession.sessionKey() } catch (e: Exception) { - Timber.e(e, "## getSessionKey() : failed") + Timber.tag(loggerTag.value).e(e, "## getSessionKey() : failed") } } return null @@ -550,8 +577,8 @@ internal class MXOlmDevice @Inject constructor( if (sessionId.isNotEmpty() && payloadString.isNotEmpty()) { try { return outboundGroupSessionCache[sessionId]!!.groupSession.encryptMessage(payloadString) - } catch (e: Exception) { - Timber.e(e, "## encryptGroupMessage() : failed") + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## encryptGroupMessage() : failed") } } return null @@ -578,52 +605,64 @@ internal class MXOlmDevice @Inject constructor( forwardingCurve25519KeyChain: List<String>, keysClaimed: Map<String, String>, exportFormat: Boolean): Boolean { - val session = OlmInboundGroupSessionWrapper2(sessionKey, exportFormat) - runCatching { getInboundGroupSession(sessionId, senderKey, roomId) } - .fold( - { - // If we already have this session, consider updating it - Timber.e("## addInboundGroupSession() : Update for megolm session $senderKey/$sessionId") - - val existingFirstKnown = it.firstKnownIndex!! - val newKnownFirstIndex = session.firstKnownIndex - - // If our existing session is better we keep it - if (newKnownFirstIndex != null && existingFirstKnown <= newKnownFirstIndex) { - session.olmInboundGroupSession?.releaseSession() - return false - } - }, - { - // Nothing to do in case of error - } - ) + val candidateSession = OlmInboundGroupSessionWrapper2(sessionKey, exportFormat) + val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) } + val existingSession = existingSessionHolder?.wrapper + // If we have an existing one we should check if the new one is not better + if (existingSession != null) { + Timber.tag(loggerTag.value).d("## addInboundGroupSession() check if known session is better than candidate session") + try { + val existingFirstKnown = existingSession.firstKnownIndex ?: return false.also { + // This is quite unexpected, could throw if native was released? + Timber.tag(loggerTag.value).e("## addInboundGroupSession() null firstKnownIndex on existing session") + candidateSession.olmInboundGroupSession?.releaseSession() + // Probably should discard it? + } + val newKnownFirstIndex = candidateSession.firstKnownIndex + // If our existing session is better we keep it + if (newKnownFirstIndex != null && existingFirstKnown <= newKnownFirstIndex) { + Timber.tag(loggerTag.value).d("## addInboundGroupSession() : ignore session our is better $senderKey/$sessionId") + candidateSession.olmInboundGroupSession?.releaseSession() + return false + } + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).e("## addInboundGroupSession() Failed to add inbound: ${failure.localizedMessage}") + candidateSession.olmInboundGroupSession?.releaseSession() + return false + } + } - // sanity check - if (null == session.olmInboundGroupSession) { - Timber.e("## addInboundGroupSession : invalid session") + Timber.tag(loggerTag.value).d("## addInboundGroupSession() : Candidate session should be added $senderKey/$sessionId") + + // sanity check on the new session + val candidateOlmInboundSession = candidateSession.olmInboundGroupSession + if (null == candidateOlmInboundSession) { + Timber.tag(loggerTag.value).e("## addInboundGroupSession : invalid session <null>") return false } try { - if (session.olmInboundGroupSession!!.sessionIdentifier() != sessionId) { - Timber.e("## addInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") - session.olmInboundGroupSession!!.releaseSession() + if (candidateOlmInboundSession.sessionIdentifier() != sessionId) { + Timber.tag(loggerTag.value).e("## addInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") + candidateOlmInboundSession.releaseSession() return false } - } catch (e: Exception) { - session.olmInboundGroupSession?.releaseSession() - Timber.e(e, "## addInboundGroupSession : sessionIdentifier() failed") + } catch (e: Throwable) { + candidateOlmInboundSession.releaseSession() + Timber.tag(loggerTag.value).e(e, "## addInboundGroupSession : sessionIdentifier() failed") return false } - session.senderKey = senderKey - session.roomId = roomId - session.keysClaimed = keysClaimed - session.forwardingCurve25519KeyChain = forwardingCurve25519KeyChain + candidateSession.senderKey = senderKey + candidateSession.roomId = roomId + candidateSession.keysClaimed = keysClaimed + candidateSession.forwardingCurve25519KeyChain = forwardingCurve25519KeyChain - inboundGroupSessionStore.storeInBoundGroupSession(session, sessionId, senderKey) -// store.storeInboundGroupSessions(listOf(session)) + if (existingSession != null) { + inboundGroupSessionStore.replaceGroupSession(existingSessionHolder, InboundGroupSessionHolder(candidateSession), sessionId, senderKey) + } else { + inboundGroupSessionStore.storeInBoundGroupSession(InboundGroupSessionHolder(candidateSession), sessionId, senderKey) + } return true } @@ -638,57 +677,70 @@ internal class MXOlmDevice @Inject constructor( val sessions = ArrayList<OlmInboundGroupSessionWrapper2>(megolmSessionsData.size) for (megolmSessionData in megolmSessionsData) { - val sessionId = megolmSessionData.sessionId - val senderKey = megolmSessionData.senderKey + val sessionId = megolmSessionData.sessionId ?: continue + val senderKey = megolmSessionData.senderKey ?: continue val roomId = megolmSessionData.roomId - var session: OlmInboundGroupSessionWrapper2? = null + var candidateSessionToImport: OlmInboundGroupSessionWrapper2? = null try { - session = OlmInboundGroupSessionWrapper2(megolmSessionData) + candidateSessionToImport = OlmInboundGroupSessionWrapper2(megolmSessionData) } catch (e: Exception) { - Timber.e(e, "## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId") + Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId") } // sanity check - if (session?.olmInboundGroupSession == null) { - Timber.e("## importInboundGroupSession : invalid session") + if (candidateSessionToImport?.olmInboundGroupSession == null) { + Timber.tag(loggerTag.value).e("## importInboundGroupSession : invalid session") continue } + val candidateOlmInboundGroupSession = candidateSessionToImport.olmInboundGroupSession try { - if (session.olmInboundGroupSession?.sessionIdentifier() != sessionId) { - Timber.e("## importInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") - if (session.olmInboundGroupSession != null) session.olmInboundGroupSession!!.releaseSession() + if (candidateOlmInboundGroupSession?.sessionIdentifier() != sessionId) { + Timber.tag(loggerTag.value).e("## importInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") + candidateOlmInboundGroupSession?.releaseSession() continue } } catch (e: Exception) { - Timber.e(e, "## importInboundGroupSession : sessionIdentifier() failed") - session.olmInboundGroupSession!!.releaseSession() + Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession : sessionIdentifier() failed") + candidateOlmInboundGroupSession?.releaseSession() continue } - runCatching { getInboundGroupSession(sessionId, senderKey, roomId) } - .fold( - { - // If we already have this session, consider updating it - Timber.e("## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId") - - // For now we just ignore updates. TODO: implement something here - if (it.firstKnownIndex!! <= session.firstKnownIndex!!) { - // Ignore this, keep existing - session.olmInboundGroupSession!!.releaseSession() - } else { - sessions.add(session) - } - Unit - }, - { - // Session does not already exist, add it - sessions.add(session) - } - - ) + val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) } + val existingSession = existingSessionHolder?.wrapper + + if (existingSession == null) { + // Session does not already exist, add it + Timber.tag(loggerTag.value).d("## importInboundGroupSession() : importing new megolm session $senderKey/$sessionId") + sessions.add(candidateSessionToImport) + } else { + Timber.tag(loggerTag.value).e("## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId") + val existingFirstKnown = tryOrNull { existingSession.firstKnownIndex } + val candidateFirstKnownIndex = tryOrNull { candidateSessionToImport.firstKnownIndex } + + if (existingFirstKnown == null || candidateFirstKnownIndex == null) { + // should not happen? + candidateSessionToImport.olmInboundGroupSession?.releaseSession() + Timber.tag(loggerTag.value) + .w("## importInboundGroupSession() : Can't check session null index $existingFirstKnown/$candidateFirstKnownIndex") + } else { + if (existingFirstKnown <= candidateSessionToImport.firstKnownIndex!!) { + // Ignore this, keep existing + candidateOlmInboundGroupSession.releaseSession() + } else { + // update cache with better session + inboundGroupSessionStore.replaceGroupSession( + existingSessionHolder, + InboundGroupSessionHolder(candidateSessionToImport), + sessionId, + senderKey + ) + sessions.add(candidateSessionToImport) + } + } + } } store.storeInboundGroupSessions(sessions) @@ -696,18 +748,6 @@ internal class MXOlmDevice @Inject constructor( return sessions } - /** - * Remove an inbound group session - * - * @param sessionId the session identifier. - * @param sessionKey base64-encoded secret key. - */ - fun removeInboundGroupSession(sessionId: String?, sessionKey: String?) { - if (null != sessionId && null != sessionKey) { - store.removeInboundGroupSession(sessionId, sessionKey) - } - } - /** * Decrypt a received message with an inbound group session. * @@ -719,19 +759,24 @@ internal class MXOlmDevice @Inject constructor( * @return the decrypting result. Nil if the sessionId is unknown. */ @Throws(MXCryptoError::class) - fun decryptGroupMessage(body: String, - roomId: String, - timeline: String?, - sessionId: String, - senderKey: String): OlmDecryptionResult { - val session = getInboundGroupSession(sessionId, senderKey, roomId) + suspend fun decryptGroupMessage(body: String, + roomId: String, + timeline: String?, + sessionId: String, + senderKey: String): OlmDecryptionResult { + val sessionHolder = getInboundGroupSession(sessionId, senderKey, roomId) + val wrapper = sessionHolder.wrapper + val inboundGroupSession = wrapper.olmInboundGroupSession + ?: throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, "Session is null") // Check that the room id matches the original one for the session. This stops // the HS pretending a message was targeting a different room. - if (roomId == session.roomId) { + if (roomId == wrapper.roomId) { val decryptResult = try { - session.olmInboundGroupSession!!.decryptMessage(body) + sessionHolder.mutex.withLock { + inboundGroupSession.decryptMessage(body) + } } catch (e: OlmException) { - Timber.e(e, "## decryptGroupMessage () : decryptMessage failed") + Timber.tag(loggerTag.value).e(e, "## decryptGroupMessage () : decryptMessage failed") throw MXCryptoError.OlmError(e) } @@ -742,32 +787,32 @@ internal class MXOlmDevice @Inject constructor( if (timelineSet.contains(messageIndexKey)) { val reason = String.format(MXCryptoError.DUPLICATE_MESSAGE_INDEX_REASON, decryptResult.mIndex) - Timber.e("## decryptGroupMessage() : $reason") + Timber.tag(loggerTag.value).e("## decryptGroupMessage() : $reason") throw MXCryptoError.Base(MXCryptoError.ErrorType.DUPLICATED_MESSAGE_INDEX, reason) } timelineSet.add(messageIndexKey) } - inboundGroupSessionStore.storeInBoundGroupSession(session, sessionId, senderKey) + inboundGroupSessionStore.storeInBoundGroupSession(sessionHolder, sessionId, senderKey) val payload = try { val adapter = MoshiProvider.providesMoshi().adapter<JsonDict>(JSON_DICT_PARAMETERIZED_TYPE) val payloadString = convertFromUTF8(decryptResult.mDecryptedMessage) adapter.fromJson(payloadString) } catch (e: Exception) { - Timber.e("## decryptGroupMessage() : fails to parse the payload") + Timber.tag(loggerTag.value).e("## decryptGroupMessage() : fails to parse the payload") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_DECRYPTED_FORMAT, MXCryptoError.BAD_DECRYPTED_FORMAT_TEXT_REASON) } return OlmDecryptionResult( payload, - session.keysClaimed, + wrapper.keysClaimed, senderKey, - session.forwardingCurve25519KeyChain + wrapper.forwardingCurve25519KeyChain ) } else { - val reason = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, session.roomId) - Timber.e("## decryptGroupMessage() : $reason") + val reason = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, wrapper.roomId) + Timber.tag(loggerTag.value).e("## decryptGroupMessage() : $reason") throw MXCryptoError.Base(MXCryptoError.ErrorType.INBOUND_SESSION_MISMATCH_ROOM_ID, reason) } } @@ -819,7 +864,7 @@ internal class MXOlmDevice @Inject constructor( private fun getSessionForDevice(theirDeviceIdentityKey: String, sessionId: String): OlmSessionWrapper? { // sanity check return if (theirDeviceIdentityKey.isEmpty() || sessionId.isEmpty()) null else { - store.getDeviceSession(sessionId, theirDeviceIdentityKey) + olmSessionStore.getDeviceSession(sessionId, theirDeviceIdentityKey) } } @@ -832,25 +877,26 @@ internal class MXOlmDevice @Inject constructor( * @param senderKey the base64-encoded curve25519 key of the sender. * @return the inbound group session. */ - fun getInboundGroupSession(sessionId: String?, senderKey: String?, roomId: String?): OlmInboundGroupSessionWrapper2 { + fun getInboundGroupSession(sessionId: String?, senderKey: String?, roomId: String?): InboundGroupSessionHolder { if (sessionId.isNullOrBlank() || senderKey.isNullOrBlank()) { throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_SENDER_KEY, MXCryptoError.ERROR_MISSING_PROPERTY_REASON) } - val session = inboundGroupSessionStore.getInboundGroupSession(sessionId, senderKey) + val holder = inboundGroupSessionStore.getInboundGroupSession(sessionId, senderKey) + val session = holder?.wrapper if (session != null) { // Check that the room id matches the original one for the session. This stops // the HS pretending a message was targeting a different room. if (roomId != session.roomId) { val errorDescription = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, session.roomId) - Timber.e("## getInboundGroupSession() : $errorDescription") + Timber.tag(loggerTag.value).e("## getInboundGroupSession() : $errorDescription") throw MXCryptoError.Base(MXCryptoError.ErrorType.INBOUND_SESSION_MISMATCH_ROOM_ID, errorDescription) } else { - return session + return holder } } else { - Timber.w("## getInboundGroupSession() : Cannot retrieve inbound group session $sessionId") + Timber.tag(loggerTag.value).w("## getInboundGroupSession() : UISI $sessionId") throw MXCryptoError.Base(MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID, MXCryptoError.UNKNOWN_INBOUND_SESSION_ID_REASON) } } @@ -866,4 +912,9 @@ internal class MXOlmDevice @Inject constructor( fun hasInboundSessionKeys(roomId: String, senderKey: String, sessionId: String): Boolean { return runCatching { getInboundGroupSession(sessionId, senderKey, roomId) }.isSuccess } + + @VisibleForTesting + fun clearOlmSessionCache() { + olmSessionStore.clear() + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/NewSessionListener.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/NewSessionListener.kt index 301729680ce2e91208e8d004f9c5a70caf174e53..9b39a8ab25842bb6fac591833a96cc4faaf91467 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/NewSessionListener.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/NewSessionListener.kt @@ -15,6 +15,15 @@ */ package org.matrix.android.sdk.internal.crypto +/** + * This listener notifies on new Megolm sessions being created + */ interface NewSessionListener { + + /** + * @param roomId the room id where the new Megolm session has been created for, may be null when importing from external sessions + * @param senderKey the sender key of the device which the Megolm session is shared with + * @param sessionId the session id of the Megolm session + */ fun onNewSession(roomId: String?, senderKey: String, sessionId: String) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmSessionStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmSessionStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..f4fbca6a0fe5bc7a29cc5f2f7eacf8d366e0c3a6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmSessionStore.kt @@ -0,0 +1,159 @@ +/* + * 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.crypto + +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.internal.crypto.model.OlmSessionWrapper +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore +import org.matrix.olm.OlmSession +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("OlmSessionStore", LoggerTag.CRYPTO) + +/** + * Keep the used olm session in memory and load them from the data layer when needed + * Access is synchronized for thread safety + */ +internal class OlmSessionStore @Inject constructor(private val store: IMXCryptoStore) { + /** + * map of device key to list of olm sessions (it is possible to have several active sessions with a device) + */ + private val olmSessions = HashMap<String, MutableList<OlmSessionWrapper>>() + + /** + * Store a session between our own device and another device. + * This will be called after the session has been created but also every time it has been used + * in order to persist the correct state for next run + * @param olmSessionWrapper the end-to-end session. + * @param deviceKey the public key of the other device. + */ + @Synchronized + fun storeSession(olmSessionWrapper: OlmSessionWrapper, deviceKey: String) { + // This could be a newly created session or one that was just created + // Anyhow we should persist ratchet state for future app lifecycle + addNewSessionInCache(olmSessionWrapper, deviceKey) + store.storeSession(olmSessionWrapper, deviceKey) + } + + /** + * Get all the Olm Sessions we are sharing with the given device. + * + * @param deviceKey the public key of the other device. + * @return A set of sessionId, or empty if device is not known + */ + @Synchronized + fun getDeviceSessionIds(deviceKey: String): List<String> { + // we need to get the persisted ids first + val persistedKnownSessions = store.getDeviceSessionIds(deviceKey) + .orEmpty() + .toMutableList() + // Do we have some in cache not yet persisted? + olmSessions.getOrPut(deviceKey) { mutableListOf() }.forEach { cached -> + getSafeSessionIdentifier(cached.olmSession)?.let { cachedSessionId -> + if (!persistedKnownSessions.contains(cachedSessionId)) { + persistedKnownSessions.add(cachedSessionId) + } + } + } + return persistedKnownSessions + } + + /** + * Retrieve an end-to-end session between our own device and another + * device. + * + * @param sessionId the session Id. + * @param deviceKey the public key of the other device. + * @return the session wrapper if found + */ + @Synchronized + fun getDeviceSession(sessionId: String, deviceKey: String): OlmSessionWrapper? { + // get from cache or load and add to cache + return internalGetSession(sessionId, deviceKey) + } + + /** + * Retrieve the last used sessionId, regarding `lastReceivedMessageTs`, or null if no session exist + * + * @param deviceKey the public key of the other device. + * @return last used sessionId, or null if not found + */ + @Synchronized + fun getLastUsedSessionId(deviceKey: String): String? { + // We want to avoid to load in memory old session if possible + val lastPersistedUsedSession = store.getLastUsedSessionId(deviceKey) + var candidate = lastPersistedUsedSession?.let { internalGetSession(it, deviceKey) } + // we should check if we have one in cache with a higher last message received? + olmSessions[deviceKey].orEmpty().forEach { inCache -> + if (inCache.lastReceivedMessageTs > (candidate?.lastReceivedMessageTs ?: 0L)) { + candidate = inCache + } + } + + return candidate?.olmSession?.sessionIdentifier() + } + + /** + * Release all sessions and clear cache + */ + @Synchronized + fun clear() { + olmSessions.entries.onEach { entry -> + entry.value.onEach { it.olmSession.releaseSession() } + } + olmSessions.clear() + } + + private fun internalGetSession(sessionId: String, deviceKey: String): OlmSessionWrapper? { + return getSessionInCache(sessionId, deviceKey) + ?: // deserialize from store + return store.getDeviceSession(sessionId, deviceKey)?.also { + addNewSessionInCache(it, deviceKey) + } + } + + private fun getSessionInCache(sessionId: String, deviceKey: String): OlmSessionWrapper? { + return olmSessions[deviceKey]?.firstOrNull { + getSafeSessionIdentifier(it.olmSession) == sessionId + } + } + + private fun getSafeSessionIdentifier(session: OlmSession): String? { + return try { + session.sessionIdentifier() + } catch (throwable: Throwable) { + Timber.tag(loggerTag.value).w("Failed to load sessionId from loaded olm session") + null + } + } + + private fun addNewSessionInCache(session: OlmSessionWrapper, deviceKey: String) { + val sessionId = getSafeSessionIdentifier(session.olmSession) ?: return + olmSessions.getOrPut(deviceKey) { mutableListOf() }.let { + val existing = it.firstOrNull { getSafeSessionIdentifier(it.olmSession) == sessionId } + it.add(session) + // remove and release if was there but with different instance + if (existing != null && existing.olmSession != session.olmSession) { + // mm not sure when this could happen + // anyhow we should remove and release the one known + it.remove(existing) + existing.olmSession.releaseSession() + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt index ab2ed04dfbe5793db1c13780bc2e9d7afd9fdd93..87c176612d7fa61e4cf65e17ae9487efa2791faf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt @@ -16,14 +16,18 @@ package org.matrix.android.sdk.internal.crypto.actions +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.internal.crypto.MXOlmDevice import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.internal.crypto.model.MXKey import org.matrix.android.sdk.internal.crypto.model.MXOlmSessionResult import org.matrix.android.sdk.internal.crypto.model.MXUsersDevicesMap -import org.matrix.android.sdk.internal.crypto.model.toDebugString import org.matrix.android.sdk.internal.crypto.tasks.ClaimOneTimeKeysForUsersDeviceTask +import org.matrix.android.sdk.internal.session.SessionScope import timber.log.Timber import javax.inject.Inject @@ -31,90 +35,90 @@ private const val ONE_TIME_KEYS_RETRY_COUNT = 3 private val loggerTag = LoggerTag("EnsureOlmSessionsForDevicesAction", LoggerTag.CRYPTO) +@SessionScope internal class EnsureOlmSessionsForDevicesAction @Inject constructor( private val olmDevice: MXOlmDevice, + private val coroutineDispatchers: MatrixCoroutineDispatchers, private val oneTimeKeysForUsersDeviceTask: ClaimOneTimeKeysForUsersDeviceTask) { - suspend fun handle(devicesByUser: Map<String, List<CryptoDeviceInfo>>, force: Boolean = false): MXUsersDevicesMap<MXOlmSessionResult> { - val devicesWithoutSession = ArrayList<CryptoDeviceInfo>() + private val ensureMutex = Mutex() - val results = MXUsersDevicesMap<MXOlmSessionResult>() + /** + * We want to synchronize a bit here, because we are iterating to check existing olm session and + * also adding some + */ + suspend fun handle(devicesByUser: Map<String, List<CryptoDeviceInfo>>, force: Boolean = false): MXUsersDevicesMap<MXOlmSessionResult> { + ensureMutex.withLock { + val results = MXUsersDevicesMap<MXOlmSessionResult>() + val deviceList = devicesByUser.flatMap { it.value } + Timber.tag(loggerTag.value) + .d("ensure olm forced:$force for ${deviceList.joinToString { it.shortDebugString() }}") + val devicesToCreateSessionWith = mutableListOf<CryptoDeviceInfo>() + if (force) { + // we take all devices and will query otk for them + devicesToCreateSessionWith.addAll(deviceList) + } else { + // only peek devices without active session + deviceList.forEach { deviceInfo -> + val deviceId = deviceInfo.deviceId + val userId = deviceInfo.userId + val key = deviceInfo.identityKey() ?: return@forEach Unit.also { + Timber.tag(loggerTag.value).w("Ignoring device ${deviceInfo.shortDebugString()} without identity key") + } - for ((userId, deviceList) in devicesByUser) { - for (deviceInfo in deviceList) { - val deviceId = deviceInfo.deviceId - val key = deviceInfo.identityKey() - if (key == null) { - Timber.w("## CRYPTO | Ignoring device (${deviceInfo.userId}|$deviceId) without identity key") - continue + // is there a session that as been already used? + val sessionId = olmDevice.getSessionId(key) + if (sessionId.isNullOrEmpty()) { + Timber.tag(loggerTag.value).d("Found no existing olm session ${deviceInfo.shortDebugString()} add to claim list") + devicesToCreateSessionWith.add(deviceInfo) + } else { + Timber.tag(loggerTag.value).d("using olm session $sessionId for (${deviceInfo.userId}|$deviceId)") + val olmSessionResult = MXOlmSessionResult(deviceInfo, sessionId) + results.setObject(userId, deviceId, olmSessionResult) + } } + } - val sessionId = olmDevice.getSessionId(key) - - if (sessionId.isNullOrEmpty() || force) { - Timber.tag(loggerTag.value).d("Found no existing olm session (${deviceInfo.userId}|$deviceId) (force=$force)") - devicesWithoutSession.add(deviceInfo) - } else { - Timber.tag(loggerTag.value).d("using olm session $sessionId for (${deviceInfo.userId}|$deviceId)") + if (devicesToCreateSessionWith.isEmpty()) { + // no session to create + return results + } + val usersDevicesToClaim = MXUsersDevicesMap<String>().apply { + devicesToCreateSessionWith.forEach { + setObject(it.userId, it.deviceId, MXKey.KEY_SIGNED_CURVE_25519_TYPE) } - - val olmSessionResult = MXOlmSessionResult(deviceInfo, sessionId) - results.setObject(userId, deviceId, olmSessionResult) } - } - - Timber.tag(loggerTag.value).d("Devices without olm session (count:${devicesWithoutSession.size}) :" + - " ${devicesWithoutSession.joinToString { "${it.userId}|${it.deviceId}" }}") - if (devicesWithoutSession.size == 0) { - return results - } - - // Prepare the request for claiming one-time keys - val usersDevicesToClaim = MXUsersDevicesMap<String>() - - val oneTimeKeyAlgorithm = MXKey.KEY_SIGNED_CURVE_25519_TYPE - for (device in devicesWithoutSession) { - usersDevicesToClaim.setObject(device.userId, device.deviceId, oneTimeKeyAlgorithm) - } + // Let's now claim one time keys + val claimParams = ClaimOneTimeKeysForUsersDeviceTask.Params(usersDevicesToClaim) + val oneTimeKeys = withContext(coroutineDispatchers.io) { + oneTimeKeysForUsersDeviceTask.executeRetry(claimParams, ONE_TIME_KEYS_RETRY_COUNT) + } - // TODO: this has a race condition - if we try to send another message - // while we are claiming a key, we will end up claiming two and setting up - // two sessions. - // - // That should eventually resolve itself, but it's poor form. - - Timber.tag(loggerTag.value).i("claimOneTimeKeysForUsersDevices() : ${usersDevicesToClaim.toDebugString()}") - - val claimParams = ClaimOneTimeKeysForUsersDeviceTask.Params(usersDevicesToClaim) - val oneTimeKeys = oneTimeKeysForUsersDeviceTask.executeRetry(claimParams, remainingRetry = ONE_TIME_KEYS_RETRY_COUNT) - Timber.tag(loggerTag.value).v("claimOneTimeKeysForUsersDevices() : keysClaimResponse.oneTimeKeys: $oneTimeKeys") - for ((userId, deviceInfos) in devicesByUser) { - for (deviceInfo in deviceInfos) { - var oneTimeKey: MXKey? = null - val deviceIds = oneTimeKeys.getUserDeviceIds(userId) - if (null != deviceIds) { - for (deviceId in deviceIds) { - val olmSessionResult = results.getObject(userId, deviceId) - if (olmSessionResult?.sessionId != null && !force) { - // We already have a result for this device - continue - } - val key = oneTimeKeys.getObject(userId, deviceId) - if (key?.type == oneTimeKeyAlgorithm) { - oneTimeKey = key - } - if (oneTimeKey == null) { - Timber.tag(loggerTag.value).d("No one time key for $userId|$deviceId") - continue - } - // Update the result for this device in results - olmSessionResult?.sessionId = verifyKeyAndStartSession(oneTimeKey, userId, deviceInfo) + // let now start olm session using the new otks + devicesToCreateSessionWith.forEach { deviceInfo -> + val userId = deviceInfo.userId + val deviceId = deviceInfo.deviceId + // Did we get an OTK + val oneTimeKey = oneTimeKeys.getObject(userId, deviceId) + if (oneTimeKey == null) { + Timber.tag(loggerTag.value).d("No otk for ${deviceInfo.shortDebugString()}") + } else if (oneTimeKey.type != MXKey.KEY_SIGNED_CURVE_25519_TYPE) { + Timber.tag(loggerTag.value).d("Bad otk type (${oneTimeKey.type}) for ${deviceInfo.shortDebugString()}") + } else { + val olmSessionId = verifyKeyAndStartSession(oneTimeKey, userId, deviceInfo) + if (olmSessionId != null) { + val olmSessionResult = MXOlmSessionResult(deviceInfo, olmSessionId) + results.setObject(userId, deviceId, olmSessionResult) + } else { + Timber + .tag(loggerTag.value) + .d("## CRYPTO | cant unwedge failed to create outbound ${deviceInfo.shortDebugString()}") } } } + return results } - return results } private fun verifyKeyAndStartSession(oneTimeKey: MXKey, userId: String, deviceInfo: CryptoDeviceInfo): String? { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt index 0d78f68e5c6527f5c5b4e17ebce369859560d171..f79b97b0818a9d73f38bcb63f892cdea6917292b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt @@ -22,6 +22,7 @@ import org.matrix.android.sdk.internal.crypto.MXOlmDevice import org.matrix.android.sdk.internal.crypto.MegolmSessionData import org.matrix.android.sdk.internal.crypto.OutgoingGossipingRequestManager import org.matrix.android.sdk.internal.crypto.RoomDecryptorProvider +import org.matrix.android.sdk.internal.crypto.algorithms.megolm.MXMegolmDecryption import org.matrix.android.sdk.internal.crypto.model.ImportRoomKeysResult import org.matrix.android.sdk.internal.crypto.model.rest.RoomKeyRequestBody import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore @@ -76,7 +77,11 @@ internal class MegolmSessionDataImporter @Inject constructor(private val olmDevi outgoingGossipingRequestManager.cancelRoomKeyRequest(roomKeyRequestBody) // Have another go at decrypting events sent with this session - decrypting.onNewSession(megolmSessionData.senderKey!!, sessionId!!) + when (decrypting) { + is MXMegolmDecryption -> { + decrypting.onNewSession(megolmSessionData.roomId, megolmSessionData.senderKey!!, sessionId!!) + } + } } catch (e: Exception) { Timber.e(e, "## importRoomKeys() : onNewSession failed") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt index 165f200bacfab78d40c09c7914bc6ae1914a85db..4e158602c8957a5d275a70093e2d9b88584afa2f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.internal.crypto.actions +import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_OLM import org.matrix.android.sdk.internal.crypto.MXOlmDevice @@ -28,6 +29,8 @@ import org.matrix.android.sdk.internal.util.convertToUTF8 import timber.log.Timber import javax.inject.Inject +private val loggerTag = LoggerTag("MessageEncrypter", LoggerTag.CRYPTO) + internal class MessageEncrypter @Inject constructor( @UserId private val userId: String, @@ -42,7 +45,7 @@ internal class MessageEncrypter @Inject constructor( * @param deviceInfos list of device infos to encrypt for. * @return the content for an m.room.encrypted event. */ - fun encryptMessage(payloadFields: Content, deviceInfos: List<CryptoDeviceInfo>): EncryptedMessage { + suspend fun encryptMessage(payloadFields: Content, deviceInfos: List<CryptoDeviceInfo>): EncryptedMessage { val deviceInfoParticipantKey = deviceInfos.associateBy { it.identityKey()!! } val payloadJson = payloadFields.toMutableMap() @@ -66,7 +69,7 @@ internal class MessageEncrypter @Inject constructor( val sessionId = olmDevice.getSessionId(deviceKey) if (!sessionId.isNullOrEmpty()) { - Timber.v("Using sessionid $sessionId for device $deviceKey") + Timber.tag(loggerTag.value).d("Using sessionid $sessionId for device $deviceKey") payloadJson["recipient"] = deviceInfo.userId payloadJson["recipient_keys"] = mapOf("ed25519" to deviceInfo.fingerprint()!!) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt index 79c7608cbf1ba9e6820f86bcb1dadf64b8cda8c5..51ddd7444226bf7bccb576f34e8e9a25213ef1f7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt @@ -36,7 +36,7 @@ internal interface IMXDecrypting { * @return the decryption information, or an error */ @Throws(MXCryptoError::class) - fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult + suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult /** * Handle a key event. @@ -45,14 +45,6 @@ internal interface IMXDecrypting { */ fun onRoomKeyEvent(event: Event, defaultKeysBackupService: DefaultKeysBackupService) {} - /** - * Check if the some messages can be decrypted with a new session - * - * @param senderKey the session sender key - * @param sessionId the session id - */ - fun onNewSession(senderKey: String, sessionId: String) {} - /** * Determine if we have the keys necessary to respond to a room key request * diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt index 1fd5061a65c8e1b99c8aca3c064d153c4275a029..6f488def0af0d366df361e76999efcfa1e0f637f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt @@ -45,7 +45,7 @@ internal interface IMXGroupEncryption { * * @return true in case of success */ - suspend fun reshareKey(sessionId: String, + suspend fun reshareKey(groupSessionId: String, userId: String, deviceId: String, senderKey: String): Boolean diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt index 2ee24dfbb064d29cfa93a99778fd6e88801e0ee6..72df59023a394cca812b9b36b5c221e90cb2b79e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt @@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.crypto.algorithms.megolm import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.crypto.MXCryptoError @@ -71,7 +72,7 @@ internal class MXMegolmDecryption(private val userId: String, // private var pendingEvents: MutableMap<String /* senderKey|sessionId */, MutableMap<String /* timelineId */, MutableList<Event>>> = HashMap() @Throws(MXCryptoError::class) - override fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { + override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { // If cross signing is enabled, we don't send request until the keys are trusted // There could be a race effect here when xsigning is enabled, we should ensure that keys was downloaded once val requestOnFail = cryptoStore.getMyCrossSigningInfo()?.isTrusted() == true @@ -79,7 +80,7 @@ internal class MXMegolmDecryption(private val userId: String, } @Throws(MXCryptoError::class) - private fun decryptEvent(event: Event, timeline: String, requestKeysOnFail: Boolean): MXEventDecryptionResult { + private suspend fun decryptEvent(event: Event, timeline: String, requestKeysOnFail: Boolean): MXEventDecryptionResult { Timber.tag(loggerTag.value).v("decryptEvent ${event.eventId}, requestKeysOnFail:$requestKeysOnFail") if (event.roomId.isNullOrBlank()) { throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_FIELDS, MXCryptoError.MISSING_FIELDS_REASON) @@ -317,19 +318,20 @@ internal class MXMegolmDecryption(private val userId: String, outgoingGossipingRequestManager.cancelRoomKeyRequest(content) - onNewSession(senderKey, roomKeyContent.sessionId) + onNewSession(roomKeyContent.roomId, senderKey, roomKeyContent.sessionId) } } /** * Check if the some messages can be decrypted with a new session * + * @param roomId the room id where the new Megolm session has been created for, may be null when importing from external sessions * @param senderKey the session sender key * @param sessionId the session id */ - override fun onNewSession(senderKey: String, sessionId: String) { + fun onNewSession(roomId: String?, senderKey: String, sessionId: String) { Timber.tag(loggerTag.value).v("ON NEW SESSION $sessionId - $senderKey") - newSessionListener?.onNewSession(null, senderKey, sessionId) + newSessionListener?.onNewSession(roomId, senderKey, sessionId) } override fun hasKeysForKeyRequest(request: IncomingRoomKeyRequest): Boolean { @@ -345,7 +347,22 @@ internal class MXMegolmDecryption(private val userId: String, return } val userId = request.userId ?: return + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { + val body = request.requestBody + val sessionHolder = try { + olmDevice.getInboundGroupSession(body.sessionId, body.senderKey, body.roomId) + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).e(failure, "shareKeysWithDevice: failed to get session for request $body") + return@launch + } + + val export = sessionHolder.mutex.withLock { + sessionHolder.wrapper.exportKeys() + } ?: return@launch Unit.also { + Timber.tag(loggerTag.value).e("shareKeysWithDevice: failed to export group session ${body.sessionId}") + } + runCatching { deviceListManager.downloadKeys(listOf(userId), false) } .mapCatching { val deviceId = request.deviceId @@ -355,7 +372,6 @@ internal class MXMegolmDecryption(private val userId: String, } else { val devicesByUser = mapOf(userId to listOf(deviceInfo)) val usersDeviceMap = ensureOlmSessionsForDevicesAction.handle(devicesByUser) - val body = request.requestBody val olmSessionResult = usersDeviceMap.getObject(userId, deviceId) if (olmSessionResult?.sessionId == null) { // no session with this device, probably because there @@ -365,19 +381,10 @@ internal class MXMegolmDecryption(private val userId: String, } Timber.tag(loggerTag.value).i("shareKeysWithDevice() : sharing session ${body.sessionId} with device $userId:$deviceId") - val payloadJson = mutableMapOf<String, Any>("type" to EventType.FORWARDED_ROOM_KEY) - runCatching { olmDevice.getInboundGroupSession(body.sessionId, body.senderKey, body.roomId) } - .fold( - { - // TODO - payloadJson["content"] = it.exportKeys() ?: "" - }, - { - // TODO - Timber.tag(loggerTag.value).e(it, "shareKeysWithDevice: failed to get session for request $body") - } - - ) + val payloadJson = mapOf( + "type" to EventType.FORWARDED_ROOM_KEY, + "content" to export + ) val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo)) val sendToDeviceMap = MXUsersDevicesMap<Any>() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt index 389036a1f86bd964907d50774e71bac5937afa44..cf9733dc2dc155fe3f4ba397e007f0f067f36b5d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt @@ -18,6 +18,8 @@ package org.matrix.android.sdk.internal.crypto.algorithms.megolm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.crypto.MXCryptoError @@ -88,7 +90,7 @@ internal class MXMegolmEncryption( Timber.tag(loggerTag.value).v("encryptEventContent : getDevicesInRoom") val devices = getDevicesInRoom(userIds) Timber.tag(loggerTag.value).d("encrypt event in room=$roomId - devices count in room ${devices.allowedDevices.toDebugCount()}") - Timber.tag(loggerTag.value).v("encryptEventContent ${System.currentTimeMillis() - ts}: getDevicesInRoom ${devices.allowedDevices.map}") + Timber.tag(loggerTag.value).v("encryptEventContent ${System.currentTimeMillis() - ts}: getDevicesInRoom ${devices.allowedDevices.toDebugString()}") val outboundSession = ensureOutboundSession(devices.allowedDevices) return encryptContent(outboundSession, eventType, eventContent) @@ -142,8 +144,9 @@ internal class MXMegolmEncryption( Timber.tag(loggerTag.value).v("prepareNewSessionInRoom() ") val sessionId = olmDevice.createOutboundGroupSessionForRoom(roomId) - val keysClaimedMap = HashMap<String, String>() - keysClaimedMap["ed25519"] = olmDevice.deviceEd25519Key!! + val keysClaimedMap = mapOf( + "ed25519" to olmDevice.deviceEd25519Key!! + ) olmDevice.addInboundGroupSession(sessionId!!, olmDevice.getSessionKey(sessionId)!!, roomId, olmDevice.deviceCurve25519Key!!, emptyList(), keysClaimedMap, false) @@ -303,11 +306,13 @@ internal class MXMegolmEncryption( Timber.tag(loggerTag.value).d("sending to device room key for ${session.sessionId} to ${contentMap.toDebugString()}") val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, contentMap) try { - sendToDeviceTask.execute(sendToDeviceParams) + withContext(coroutineDispatchers.io) { + sendToDeviceTask.execute(sendToDeviceParams) + } Timber.tag(loggerTag.value).i("shareUserDevicesKey() : sendToDevice succeeds after ${System.currentTimeMillis() - t0} ms") } catch (failure: Throwable) { // What to do here... - Timber.tag(loggerTag.value).e("shareUserDevicesKey() : Failed to share session <${session.sessionId}> with $devicesByUser ") + Timber.tag(loggerTag.value).e("shareUserDevicesKey() : Failed to share <${session.sessionId}>") } } else { Timber.tag(loggerTag.value).i("shareUserDevicesKey() : no need to share key") @@ -346,9 +351,12 @@ internal class MXMegolmEncryption( } ) try { - sendToDeviceTask.execute(params) + withContext(coroutineDispatchers.io) { + sendToDeviceTask.execute(params) + } } catch (failure: Throwable) { - Timber.tag(loggerTag.value).e("notifyKeyWithHeld() : Failed to notify withheld key for $targets session: $sessionId ") + Timber.tag(loggerTag.value) + .e("notifyKeyWithHeld() :$sessionId Failed to send withheld ${targets.map { "${it.userId}|${it.deviceId}" }}") } } @@ -432,20 +440,20 @@ internal class MXMegolmEncryption( } } - override suspend fun reshareKey(sessionId: String, + override suspend fun reshareKey(groupSessionId: String, userId: String, deviceId: String, senderKey: String): Boolean { - Timber.tag(loggerTag.value).i("process reshareKey for $sessionId to $userId:$deviceId") + Timber.tag(loggerTag.value).i("process reshareKey for $groupSessionId to $userId:$deviceId") val deviceInfo = cryptoStore.getUserDevice(userId, deviceId) ?: return false .also { Timber.tag(loggerTag.value).w("reshareKey: Device not found") } // Get the chain index of the key we previously sent this device - val wasSessionSharedWithUser = cryptoStore.getSharedSessionInfo(roomId, sessionId, deviceInfo) + val wasSessionSharedWithUser = cryptoStore.getSharedSessionInfo(roomId, groupSessionId, deviceInfo) if (!wasSessionSharedWithUser.found) { // This session was never shared with this user // Send a room key with held - notifyKeyWithHeld(listOf(UserDevice(userId, deviceId)), sessionId, senderKey, WithHeldCode.UNAUTHORISED) + notifyKeyWithHeld(listOf(UserDevice(userId, deviceId)), groupSessionId, senderKey, WithHeldCode.UNAUTHORISED) Timber.tag(loggerTag.value).w("reshareKey: ERROR : Never shared megolm with this device") return false } @@ -456,42 +464,47 @@ internal class MXMegolmEncryption( } val devicesByUser = mapOf(userId to listOf(deviceInfo)) - val usersDeviceMap = ensureOlmSessionsForDevicesAction.handle(devicesByUser) - val olmSessionResult = usersDeviceMap.getObject(userId, deviceId) - olmSessionResult?.sessionId // no session with this device, probably because there were no one-time keys. - // ensureOlmSessionsForDevicesAction has already done the logging, so just skip it. - ?: return false.also { - Timber.tag(loggerTag.value).w("reshareKey: no session with this device, probably because there were no one-time keys") - } - - Timber.tag(loggerTag.value).i(" reshareKey: sharing keys for session $senderKey|$sessionId:$chainIndex with device $userId:$deviceId") + val usersDeviceMap = try { + ensureOlmSessionsForDevicesAction.handle(devicesByUser) + } catch (failure: Throwable) { + null + } + val olmSessionResult = usersDeviceMap?.getObject(userId, deviceId) + if (olmSessionResult?.sessionId == null) { + Timber.tag(loggerTag.value).w("reshareKey: no session with this device, probably because there were no one-time keys") + return false + } + Timber.tag(loggerTag.value).i(" reshareKey: $groupSessionId:$chainIndex with device $userId:$deviceId using session ${olmSessionResult.sessionId}") - val payloadJson = mutableMapOf<String, Any>("type" to EventType.FORWARDED_ROOM_KEY) + val sessionHolder = try { + olmDevice.getInboundGroupSession(groupSessionId, senderKey, roomId) + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).e(failure, "shareKeysWithDevice: failed to get session $groupSessionId") + return false + } - runCatching { olmDevice.getInboundGroupSession(sessionId, senderKey, roomId) } - .fold( - { - // TODO - payloadJson["content"] = it.exportKeys(chainIndex.toLong()) ?: "" - }, - { - // TODO - Timber.tag(loggerTag.value).e(it, "reshareKey: failed to get session $sessionId|$senderKey|$roomId") - } + val export = sessionHolder.mutex.withLock { + sessionHolder.wrapper.exportKeys() + } ?: return false.also { + Timber.tag(loggerTag.value).e("shareKeysWithDevice: failed to export group session $groupSessionId") + } - ) + val payloadJson = mapOf( + "type" to EventType.FORWARDED_ROOM_KEY, + "content" to export + ) val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo)) val sendToDeviceMap = MXUsersDevicesMap<Any>() sendToDeviceMap.setObject(userId, deviceId, encodedPayload) - Timber.tag(loggerTag.value).i("reshareKey() : sending session $sessionId to $userId:$deviceId") + Timber.tag(loggerTag.value).i("reshareKey() : sending session $groupSessionId to $userId:$deviceId") val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap) return try { sendToDeviceTask.execute(sendToDeviceParams) - Timber.tag(loggerTag.value).i("reshareKey() : successfully send <$sessionId> to $userId:$deviceId") + Timber.tag(loggerTag.value).i("reshareKey() : successfully send <$groupSessionId> to $userId:$deviceId") true } catch (failure: Throwable) { - Timber.tag(loggerTag.value).e(failure, "reshareKey() : fail to send <$sessionId> to $userId:$deviceId") + Timber.tag(loggerTag.value).e(failure, "reshareKey() : fail to send <$groupSessionId> to $userId:$deviceId") false } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt index f1bca4fbc6b181c0f6902ea8835bfd7aa3370e4a..afa249801d90c1bc398aa146d2d86873767303be 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt @@ -16,6 +16,8 @@ package org.matrix.android.sdk.internal.crypto.algorithms.olm +import kotlinx.coroutines.sync.withLock +import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.toModel @@ -30,6 +32,7 @@ import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.util.convertFromUTF8 import timber.log.Timber +private val loggerTag = LoggerTag("MXOlmDecryption", LoggerTag.CRYPTO) internal class MXOlmDecryption( // The olm device interface private val olmDevice: MXOlmDevice, @@ -38,27 +41,27 @@ internal class MXOlmDecryption( IMXDecrypting { @Throws(MXCryptoError::class) - override fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { + override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { val olmEventContent = event.content.toModel<OlmEventContent>() ?: run { - Timber.e("## decryptEvent() : bad event format") + Timber.tag(loggerTag.value).e("## decryptEvent() : bad event format") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_EVENT_FORMAT, MXCryptoError.BAD_EVENT_FORMAT_TEXT_REASON) } val cipherText = olmEventContent.ciphertext ?: run { - Timber.e("## decryptEvent() : missing cipher text") + Timber.tag(loggerTag.value).e("## decryptEvent() : missing cipher text") throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_CIPHER_TEXT, MXCryptoError.MISSING_CIPHER_TEXT_REASON) } val senderKey = olmEventContent.senderKey ?: run { - Timber.e("## decryptEvent() : missing sender key") + Timber.tag(loggerTag.value).e("## decryptEvent() : missing sender key") throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_SENDER_KEY, MXCryptoError.MISSING_SENDER_KEY_TEXT_REASON) } val messageAny = cipherText[olmDevice.deviceCurve25519Key] ?: run { - Timber.e("## decryptEvent() : our device ${olmDevice.deviceCurve25519Key} is not included in recipients") + Timber.tag(loggerTag.value).e("## decryptEvent() : our device ${olmDevice.deviceCurve25519Key} is not included in recipients") throw MXCryptoError.Base(MXCryptoError.ErrorType.NOT_INCLUDE_IN_RECIPIENTS, MXCryptoError.NOT_INCLUDED_IN_RECIPIENT_REASON) } @@ -69,7 +72,7 @@ internal class MXOlmDecryption( val decryptedPayload = decryptMessage(message, senderKey) if (decryptedPayload == null) { - Timber.e("## decryptEvent() Failed to decrypt Olm event (id= ${event.eventId} from $senderKey") + Timber.tag(loggerTag.value).e("## decryptEvent() Failed to decrypt Olm event (id= ${event.eventId} from $senderKey") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE, MXCryptoError.BAD_ENCRYPTED_MESSAGE_REASON) } val payloadString = convertFromUTF8(decryptedPayload) @@ -78,30 +81,30 @@ internal class MXOlmDecryption( val payload = adapter.fromJson(payloadString) if (payload == null) { - Timber.e("## decryptEvent failed : null payload") + Timber.tag(loggerTag.value).e("## decryptEvent failed : null payload") throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, MXCryptoError.MISSING_CIPHER_TEXT_REASON) } val olmPayloadContent = OlmPayloadContent.fromJsonString(payloadString) ?: run { - Timber.e("## decryptEvent() : bad olmPayloadContent format") + Timber.tag(loggerTag.value).e("## decryptEvent() : bad olmPayloadContent format") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_DECRYPTED_FORMAT, MXCryptoError.BAD_DECRYPTED_FORMAT_TEXT_REASON) } if (olmPayloadContent.recipient.isNullOrBlank()) { val reason = String.format(MXCryptoError.ERROR_MISSING_PROPERTY_REASON, "recipient") - Timber.e("## decryptEvent() : $reason") + Timber.tag(loggerTag.value).e("## decryptEvent() : $reason") throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_PROPERTY, reason) } if (olmPayloadContent.recipient != userId) { - Timber.e("## decryptEvent() : Event ${event.eventId}:" + + Timber.tag(loggerTag.value).e("## decryptEvent() : Event ${event.eventId}:" + " Intended recipient ${olmPayloadContent.recipient} does not match our id $userId") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_RECIPIENT, String.format(MXCryptoError.BAD_RECIPIENT_REASON, olmPayloadContent.recipient)) } val recipientKeys = olmPayloadContent.recipientKeys ?: run { - Timber.e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'recipient_keys'" + + Timber.tag(loggerTag.value).e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'recipient_keys'" + " property; cannot prevent unknown-key attack") throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_PROPERTY, String.format(MXCryptoError.ERROR_MISSING_PROPERTY_REASON, "recipient_keys")) @@ -110,31 +113,34 @@ internal class MXOlmDecryption( val ed25519 = recipientKeys["ed25519"] if (ed25519 != olmDevice.deviceEd25519Key) { - Timber.e("## decryptEvent() : Event ${event.eventId}: Intended recipient ed25519 key $ed25519 did not match ours") + Timber.tag(loggerTag.value).e("## decryptEvent() : Event ${event.eventId}: Intended recipient ed25519 key $ed25519 did not match ours") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_RECIPIENT_KEY, MXCryptoError.BAD_RECIPIENT_KEY_REASON) } if (olmPayloadContent.sender.isNullOrBlank()) { - Timber.e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'sender' property; cannot prevent unknown-key attack") + Timber.tag(loggerTag.value) + .e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'sender' property; cannot prevent unknown-key attack") throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_PROPERTY, String.format(MXCryptoError.ERROR_MISSING_PROPERTY_REASON, "sender")) } if (olmPayloadContent.sender != event.senderId) { - Timber.e("Event ${event.eventId}: original sender ${olmPayloadContent.sender} does not match reported sender ${event.senderId}") + Timber.tag(loggerTag.value) + .e("Event ${event.eventId}: sender ${olmPayloadContent.sender} does not match reported sender ${event.senderId}") throw MXCryptoError.Base(MXCryptoError.ErrorType.FORWARDED_MESSAGE, String.format(MXCryptoError.FORWARDED_MESSAGE_REASON, olmPayloadContent.sender)) } if (olmPayloadContent.roomId != event.roomId) { - Timber.e("## decryptEvent() : Event ${event.eventId}: original room ${olmPayloadContent.roomId} does not match reported room ${event.roomId}") + Timber.tag(loggerTag.value) + .e("## decryptEvent() : Event ${event.eventId}: room ${olmPayloadContent.roomId} does not match reported room ${event.roomId}") throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_ROOM, String.format(MXCryptoError.BAD_ROOM_REASON, olmPayloadContent.roomId)) } val keys = olmPayloadContent.keys ?: run { - Timber.e("## decryptEvent failed : null keys") + Timber.tag(loggerTag.value).e("## decryptEvent failed : null keys") throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, MXCryptoError.MISSING_CIPHER_TEXT_REASON) } @@ -153,8 +159,8 @@ internal class MXOlmDecryption( * @param message message object, with 'type' and 'body' fields. * @return payload, if decrypted successfully. */ - private fun decryptMessage(message: JsonDict, theirDeviceIdentityKey: String): String? { - val sessionIds = olmDevice.getSessionIds(theirDeviceIdentityKey).orEmpty() + private suspend fun decryptMessage(message: JsonDict, theirDeviceIdentityKey: String): String? { + val sessionIds = olmDevice.getSessionIds(theirDeviceIdentityKey) val messageBody = message["body"] as? String ?: return null val messageType = when (val typeAsVoid = message["type"]) { @@ -166,11 +172,32 @@ internal class MXOlmDecryption( // Try each session in turn // decryptionErrors = {}; + + val isPreKey = messageType == 0 + // we want to synchronize on prekey if not we could end up create two olm sessions + // Not very clear but it looks like the js-sdk for consistency + return if (isPreKey) { + olmDevice.mutex.withLock { + reallyDecryptMessage(sessionIds, messageBody, messageType, theirDeviceIdentityKey) + } + } else { + reallyDecryptMessage(sessionIds, messageBody, messageType, theirDeviceIdentityKey) + } + } + + private suspend fun reallyDecryptMessage(sessionIds: List<String>, messageBody: String, messageType: Int, theirDeviceIdentityKey: String): String? { + Timber.tag(loggerTag.value).d("decryptMessage() try to decrypt olm message type:$messageType from ${sessionIds.size} known sessions") for (sessionId in sessionIds) { - val payload = olmDevice.decryptMessage(messageBody, messageType, sessionId, theirDeviceIdentityKey) + val payload = try { + olmDevice.decryptMessage(messageBody, messageType, sessionId, theirDeviceIdentityKey) + } catch (throwable: Exception) { + // As we are trying one by one, we don't really care of the error here + Timber.tag(loggerTag.value).d("decryptMessage() failed with session $sessionId") + null + } if (null != payload) { - Timber.v("## decryptMessage() : Decrypted Olm message from $theirDeviceIdentityKey with session $sessionId") + Timber.tag(loggerTag.value).v("## decryptMessage() : Decrypted Olm message from $theirDeviceIdentityKey with session $sessionId") return payload } else { val foundSession = olmDevice.matchesSession(theirDeviceIdentityKey, sessionId, messageType, messageBody) @@ -178,7 +205,7 @@ internal class MXOlmDecryption( if (foundSession) { // Decryption failed, but it was a prekey message matching this // session, so it should have worked. - Timber.e("## decryptMessage() : Error decrypting prekey message with existing session id $sessionId:TODO") + Timber.tag(loggerTag.value).e("## decryptMessage() : Error decrypting prekey message with existing session id $sessionId:TODO") return null } } @@ -189,9 +216,9 @@ internal class MXOlmDecryption( // didn't work. if (sessionIds.isEmpty()) { - Timber.e("## decryptMessage() : No existing sessions") + Timber.tag(loggerTag.value).e("## decryptMessage() : No existing sessions") } else { - Timber.e("## decryptMessage() : Error decrypting non-prekey message with existing sessions") + Timber.tag(loggerTag.value).e("## decryptMessage() : Error decrypting non-prekey message with existing sessions") } return null @@ -199,14 +226,17 @@ internal class MXOlmDecryption( // prekey message which doesn't match any existing sessions: make a new // session. + // XXXX Possible races here? if concurrent access for same prekey message, we might create 2 sessions? + Timber.tag(loggerTag.value).d("## decryptMessage() : Create inbound group session from prekey sender:$theirDeviceIdentityKey") + val res = olmDevice.createInboundSession(theirDeviceIdentityKey, messageType, messageBody) if (null == res) { - Timber.e("## decryptMessage() : Error decrypting non-prekey message with existing sessions") + Timber.tag(loggerTag.value).e("## decryptMessage() : Error decrypting non-prekey message with existing sessions") return null } - Timber.v("## decryptMessage() : Created new inbound Olm session get id ${res["session_id"]} with $theirDeviceIdentityKey") + Timber.tag(loggerTag.value).v("## decryptMessage() : Created new inbound Olm session get id ${res["session_id"]} with $theirDeviceIdentityKey") return res["payload"] } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt index 5cd647ff6fa05058ca1189f4638c32e0a1686341..794ab0453321bf30a86ab247c81bbf3ecb034304 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt @@ -52,7 +52,7 @@ import timber.log.Timber import javax.inject.Inject internal class UpdateTrustWorker(context: Context, params: WorkerParameters, sessionManager: SessionManager) : - SessionSafeCoroutineWorker<UpdateTrustWorker.Params>(context, params, sessionManager, Params::class.java) { + SessionSafeCoroutineWorker<UpdateTrustWorker.Params>(context, params, sessionManager, Params::class.java) { @JsonClass(generateAdapter = true) internal data class Params( @@ -96,7 +96,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses if (userList.isNotEmpty()) { // Unfortunately we don't have much info on what did exactly changed (is it the cross signing keys of that user, // or a new device?) So we check all again :/ - Timber.d("## CrossSigning - Updating trust for users: ${userList.logLimit()}") + Timber.v("## CrossSigning - Updating trust for users: ${userList.logLimit()}") updateTrust(userList) } @@ -148,7 +148,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses myUserId -> myTrustResult else -> { crossSigningService.checkOtherMSKTrusted(myCrossSigningInfo, entry.value).also { - Timber.d("## CrossSigning - user:${entry.key} result:$it") + Timber.v("## CrossSigning - user:${entry.key} result:$it") } } } @@ -178,7 +178,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses // Update trust if needed devicesEntities?.forEach { device -> val crossSignedVerified = trustMap?.get(device)?.isCrossSignedVerified() - Timber.d("## CrossSigning - Trust for ${device.userId}|${device.deviceId} : cross verified: ${trustMap?.get(device)}") + Timber.v("## CrossSigning - Trust for ${device.userId}|${device.deviceId} : cross verified: ${trustMap?.get(device)}") if (device.trustLevelEntity?.crossSignedVerified != crossSignedVerified) { Timber.d("## CrossSigning - Trust change detected for ${device.userId}|${device.deviceId} : cross verified: $crossSignedVerified") // need to save @@ -216,7 +216,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses .equalTo(RoomSummaryEntityFields.IS_ENCRYPTED, true) .findFirst() ?.let { roomSummary -> - Timber.d("## CrossSigning - Check shield state for room $roomId") + Timber.v("## CrossSigning - Check shield state for room $roomId") val allActiveRoomMembers = RoomMemberHelper(sessionRealm, roomId).getActiveRoomMemberIds() try { val updatedTrust = computeRoomShield( @@ -277,7 +277,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses cryptoRealm: Realm, activeMemberUserIds: List<String>, roomSummaryEntity: RoomSummaryEntity): RoomEncryptionTrustLevel { - Timber.d("## CrossSigning - computeRoomShield ${roomSummaryEntity.roomId} -> ${activeMemberUserIds.logLimit()}") + Timber.v("## CrossSigning - computeRoomShield ${roomSummaryEntity.roomId} -> ${activeMemberUserIds.logLimit()}") // The set of “all users†depends on the type of room: // For regular / topic rooms which have more than 2 members (including yourself) are considered when decorating a room // For 1:1 and group DM rooms, all other users (i.e. excluding yourself) are considered when decorating a room diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt index b20168eaa3cd25ffe869d8ce252ae3595ee8f142..954c2dbe43185fa4a0b3b0c035e03bf5d24ec018 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt @@ -671,7 +671,6 @@ internal class DefaultKeysBackupService @Inject constructor( Timber.e("restoreKeysWithRecoveryKey: Invalid recovery key for this keys version") throw InvalidParameterException("Invalid recovery key") } - // Get a PK decryption instance pkDecryptionFromRecoveryKey(recoveryKey) } @@ -681,6 +680,10 @@ internal class DefaultKeysBackupService @Inject constructor( throw InvalidParameterException("Invalid recovery key") } + // Save for next time and for gossiping + // Save now as it's valid, don't wait for the import as it could take long. + saveBackupRecoveryKey(recoveryKey, keysVersionResult.version) + stepProgressListener?.onStepProgress(StepProgressListener.Step.DownloadingKey) // Get backed up keys from the homeserver @@ -729,8 +732,6 @@ internal class DefaultKeysBackupService @Inject constructor( if (backUp) { maybeBackupKeys() } - // Save for next time and for gossiping - saveBackupRecoveryKey(recoveryKey, keysVersionResult.version) result } }.foldToCallback(callback) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt index 5e7744853aa464a9515fb1fb9c807e1c5321617d..b3638dc414d60976d7054ff2e421856d8f0c37ea 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt @@ -70,6 +70,8 @@ data class CryptoDeviceInfo( keys?.let { map["keys"] = it } return map } + + fun shortDebugString() = "$userId|$deviceId" } internal fun CryptoDeviceInfo.toRest(): DeviceKeys { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXUsersDevicesMap.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXUsersDevicesMap.kt index 662541428e91bedaa19fee4ffee382a682a20314..bdb00dce8e608ee2855650974fe1315a7a3e4982 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXUsersDevicesMap.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXUsersDevicesMap.kt @@ -130,7 +130,7 @@ inline fun <T> MXUsersDevicesMap<T>.forEach(action: (String, String, T) -> Unit) } } -internal fun <T> MXUsersDevicesMap<T>.toDebugString() = +internal fun <T> MXUsersDevicesMap<T>.toDebugString() = map.entries.joinToString { "${it.key} [${it.value.keys.joinToString { it }}]" } internal fun <T> MXUsersDevicesMap<T>.toDebugCount() = diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt index 15b92f105ae63d2ccac18b885ee11f198eb89e25..263cb3b0362e82fa0bde0ea11dd0d714fb77fee4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.internal.crypto.model +import kotlinx.coroutines.sync.Mutex import org.matrix.olm.OlmSession /** @@ -25,7 +26,10 @@ data class OlmSessionWrapper( // The associated olm session. val olmSession: OlmSession, // Timestamp at which the session last received a message. - var lastReceivedMessageTs: Long = 0) { + var lastReceivedMessageTs: Long = 0, + + val mutex: Mutex = Mutex() +) { /** * Notify that a message has been received on this olm session so that it updates `lastReceivedMessageTs` diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt index 96ea5c03fa2540533d33558d08d6e1dafdf55853..e662ff74e72fd48ae50917c35e5193eb60f4d4c3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt @@ -54,7 +54,7 @@ internal interface IMXCryptoStore { /** * @return the olm account */ - fun getOlmAccount(): OlmAccount + fun <T> doWithOlmAccount(block: (OlmAccount) -> T): T fun getOrCreateOlmAccount(): OlmAccount @@ -261,7 +261,7 @@ internal interface IMXCryptoStore { fun storeSession(olmSessionWrapper: OlmSessionWrapper, deviceKey: String) /** - * Retrieve the end-to-end session ids between the logged-in user and another + * Retrieve all end-to-end session ids between our own device and another * device. * * @param deviceKey the public key of the other device. @@ -270,7 +270,7 @@ internal interface IMXCryptoStore { fun getDeviceSessionIds(deviceKey: String): List<String>? /** - * Retrieve an end-to-end session between the logged-in user and another + * Retrieve an end-to-end session between our own device and another * device. * * @param sessionId the session Id. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt index a07827c033a94c7c58b927600ca47ef1d45cfa7d..585b3d2d25c2ed01676809ac03c7b0ca09b35a1f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt @@ -104,7 +104,6 @@ import timber.log.Timber import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.inject.Inject -import kotlin.collections.set @SessionScope internal class RealmCryptoStore @Inject constructor( @@ -124,12 +123,6 @@ internal class RealmCryptoStore @Inject constructor( // The olm account private var olmAccount: OlmAccount? = null - // Cache for OlmSession, to release them properly - private val olmSessionsToRelease = HashMap<String, OlmSessionWrapper>() - - // Cache for InboundGroupSession, to release them properly - private val inboundGroupSessionToRelease = HashMap<String, OlmInboundGroupSessionWrapper2>() - private val newSessionListeners = ArrayList<NewSessionListener>() override fun addNewSessionListener(listener: NewSessionListener) { @@ -213,16 +206,6 @@ internal class RealmCryptoStore @Inject constructor( monarchyWriteAsyncExecutor.awaitTermination(1, TimeUnit.MINUTES) } - olmSessionsToRelease.forEach { - it.value.olmSession.releaseSession() - } - olmSessionsToRelease.clear() - - inboundGroupSessionToRelease.forEach { - it.value.olmInboundGroupSession?.releaseSession() - } - inboundGroupSessionToRelease.clear() - olmAccount?.releaseAccount() realmLocker?.close() @@ -247,10 +230,18 @@ internal class RealmCryptoStore @Inject constructor( } } - override fun getOlmAccount(): OlmAccount { - return olmAccount!! + /** + * Olm account access should be synchronized + */ + override fun <T> doWithOlmAccount(block: (OlmAccount) -> T): T { + return olmAccount!!.let { olmAccount -> + synchronized(olmAccount) { + block.invoke(olmAccount) + } + } } + @Synchronized override fun getOrCreateOlmAccount(): OlmAccount { doRealmTransaction(realmConfiguration) { val metaData = it.where<CryptoMetadataEntity>().findFirst() @@ -680,13 +671,6 @@ internal class RealmCryptoStore @Inject constructor( if (sessionIdentifier != null) { val key = OlmSessionEntity.createPrimaryKey(sessionIdentifier, deviceKey) - // Release memory of previously known session, if it is not the same one - if (olmSessionsToRelease[key]?.olmSession != olmSessionWrapper.olmSession) { - olmSessionsToRelease[key]?.olmSession?.releaseSession() - } - - olmSessionsToRelease[key] = olmSessionWrapper - doRealmTransaction(realmConfiguration) { val realmOlmSession = OlmSessionEntity().apply { primaryKey = key @@ -703,23 +687,18 @@ internal class RealmCryptoStore @Inject constructor( override fun getDeviceSession(sessionId: String, deviceKey: String): OlmSessionWrapper? { val key = OlmSessionEntity.createPrimaryKey(sessionId, deviceKey) - - // If not in cache (or not found), try to read it from realm - if (olmSessionsToRelease[key] == null) { - doRealmQueryAndCopy(realmConfiguration) { - it.where<OlmSessionEntity>() - .equalTo(OlmSessionEntityFields.PRIMARY_KEY, key) - .findFirst() - } - ?.let { - val olmSession = it.getOlmSession() - if (olmSession != null && it.sessionId != null) { - olmSessionsToRelease[key] = OlmSessionWrapper(olmSession, it.lastReceivedMessageTs) - } - } + return doRealmQueryAndCopy(realmConfiguration) { + it.where<OlmSessionEntity>() + .equalTo(OlmSessionEntityFields.PRIMARY_KEY, key) + .findFirst() } - - return olmSessionsToRelease[key] + ?.let { + val olmSession = it.getOlmSession() + if (olmSession != null && it.sessionId != null) { + return@let OlmSessionWrapper(olmSession, it.lastReceivedMessageTs) + } + null + } } override fun getLastUsedSessionId(deviceKey: String): String? { @@ -761,13 +740,6 @@ internal class RealmCryptoStore @Inject constructor( if (sessionIdentifier != null) { val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionIdentifier, session.senderKey) - // Release memory of previously known session, if it is not the same one - if (inboundGroupSessionToRelease[key] != session) { - inboundGroupSessionToRelease[key]?.olmInboundGroupSession?.releaseSession() - } - - inboundGroupSessionToRelease[key] = session - val realmOlmInboundGroupSession = OlmInboundGroupSessionEntity().apply { primaryKey = key sessionId = sessionIdentifier @@ -784,20 +756,12 @@ internal class RealmCryptoStore @Inject constructor( override fun getInboundGroupSession(sessionId: String, senderKey: String): OlmInboundGroupSessionWrapper2? { val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionId, senderKey) - // If not in cache (or not found), try to read it from realm - if (inboundGroupSessionToRelease[key] == null) { - doWithRealm(realmConfiguration) { - it.where<OlmInboundGroupSessionEntity>() - .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key) - .findFirst() - ?.getInboundGroupSession() - } - ?.let { - inboundGroupSessionToRelease[key] = it - } + return doWithRealm(realmConfiguration) { + it.where<OlmInboundGroupSessionEntity>() + .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key) + .findFirst() + ?.getInboundGroupSession() } - - return inboundGroupSessionToRelease[key] } override fun getCurrentOutboundGroupSessionForRoom(roomId: String): OutboundGroupSessionWrapper? { @@ -853,10 +817,6 @@ internal class RealmCryptoStore @Inject constructor( override fun removeInboundGroupSession(sessionId: String, senderKey: String) { val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionId, senderKey) - // Release memory of previously known session - inboundGroupSessionToRelease[key]?.olmInboundGroupSession?.releaseSession() - inboundGroupSessionToRelease.remove(key) - doRealmTransaction(realmConfiguration) { it.where<OlmInboundGroupSessionEntity>() .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tools/HkdfSha256.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tools/HkdfSha256.kt index 6839ccd3262c5679c27d3e977196a0405f0e1752..04ce0d85002c67c83489fe184286115c1f760938 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tools/HkdfSha256.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tools/HkdfSha256.kt @@ -70,7 +70,7 @@ object HkdfSha256 { T(2) = HMAC-Hash(PRK, T(1) | info | 0x02) T(3) = HMAC-Hash(PRK, T(2) | info | 0x03) ... - */ + */ val n = ceil(outputLength.toDouble() / HASH_LEN.toDouble()).toInt() var stepHash = ByteArray(0) // T(0) empty string (zero length) 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 388ecb965952802695fff2d543fa7b8347599a9d..bd623575fafdbfcdfe4821c082189e8076488825 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 @@ -364,14 +364,14 @@ internal class DefaultVerificationService @Inject constructor( dispatchRequestAdded(pendingVerificationRequest) /* - * After the m.key.verification.ready event is sent, either party can send an m.key.verification.start event - * to begin the verification. - * If both parties send an m.key.verification.start event, and they both specify the same verification method, - * then the event sent by the user whose user ID is the smallest is used, and the other m.key.verification.start - * event is ignored. - * In the case of a single user verifying two of their devices, the device ID is compared instead. - * If both parties send an m.key.verification.start event, but they specify different verification methods, - * the verification should be cancelled with a code of m.unexpected_message. + * After the m.key.verification.ready event is sent, either party can send an m.key.verification.start event + * to begin the verification. + * If both parties send an m.key.verification.start event, and they both specify the same verification method, + * then the event sent by the user whose user ID is the smallest is used, and the other m.key.verification.start + * event is ignored. + * In the case of a single user verifying two of their devices, the device ID is compared instead. + * If both parties send an m.key.verification.start event, but they specify different verification methods, + * the verification should be cancelled with a code of m.unexpected_message. */ } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt index 829e066bf3924ef1a8f40d06ff0cf5867fa536ec..90ede18dc8b1969143b6d674c3d3810ef00431cd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt @@ -29,7 +29,6 @@ import org.matrix.android.sdk.internal.crypto.crosssigning.fromBase64Safe import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.crypto.verification.DefaultVerificationTransaction import org.matrix.android.sdk.internal.crypto.verification.ValidVerificationInfoStart -import org.matrix.android.sdk.internal.util.exhaustive import timber.log.Timber internal class DefaultQrCodeVerificationTransaction( @@ -129,7 +128,7 @@ internal class DefaultQrCodeVerificationTransaction( // Nothing special here, we will send a reciprocate start event, and then the other session will trust it's view of the MSK } } - }.exhaustive + } val toVerifyDeviceIds = mutableListOf<String>() @@ -174,7 +173,7 @@ internal class DefaultQrCodeVerificationTransaction( Unit } } - }.exhaustive + } if (!canTrustOtherUserMasterKey && toVerifyDeviceIds.isEmpty()) { // Nothing to verify @@ -272,6 +271,7 @@ internal class DefaultQrCodeVerificationTransaction( // I now know that i can trust my MSK trust(true, emptyList(), true) } + null -> Unit } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 12e60da114541268264c3c84b74c8eafa26b24ce..a57397dad5f10595c3c4fa72d1e32ced108f73e2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -43,6 +43,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo022 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo023 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo024 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo025 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo026 import org.matrix.android.sdk.internal.util.Normalizer import timber.log.Timber import javax.inject.Inject @@ -57,7 +58,7 @@ internal class RealmSessionStoreMigration @Inject constructor( override fun equals(other: Any?) = other is RealmSessionStoreMigration override fun hashCode() = 1000 - val schemaVersion = 25L + val schemaVersion = 26L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Realm Session from $oldVersion to $newVersion") @@ -87,5 +88,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 23) MigrateSessionTo023(realm).perform() if (oldVersion < 24) MigrateSessionTo024(realm).perform() if (oldVersion < 25) MigrateSessionTo025(realm).perform() + if (oldVersion < 26) MigrateSessionTo026(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt index 289db9fa15b4351adc99ae2df221d3fd619cbd60..d2e3e99b755a7e52a63ed661e6c19a1438c3adc5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt @@ -82,17 +82,18 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity, internal fun ChunkEntity.addTimelineEvent(roomId: String, eventEntity: EventEntity, direction: PaginationDirection, - roomMemberContentsByUser: Map<String, RoomMemberContent?>? = null) { + ownedByThreadChunk: Boolean = false, + roomMemberContentsByUser: Map<String, RoomMemberContent?>? = null): TimelineEventEntity? { val eventId = eventEntity.eventId if (timelineEvents.find(eventId) != null) { - return + return null } val displayIndex = nextDisplayIndex(direction) val localId = TimelineEventEntity.nextId(realm) val senderId = eventEntity.sender ?: "" // Update RR for the sender of a new message with a dummy one - val readReceiptsSummaryEntity = handleReadReceipts(realm, roomId, eventEntity, senderId) + val readReceiptsSummaryEntity = if (!ownedByThreadChunk) handleReadReceipts(realm, roomId, eventEntity, senderId) else null val timelineEventEntity = realm.createObject<TimelineEventEntity>().apply { this.localId = localId this.root = eventEntity @@ -102,6 +103,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, ?.also { it.cleanUp(eventEntity.sender) } this.readReceipts = readReceiptsSummaryEntity this.displayIndex = displayIndex + this.ownedByThreadChunk = ownedByThreadChunk val roomMemberContent = roomMemberContentsByUser?.get(senderId) this.senderAvatar = roomMemberContent?.avatarUrl this.senderName = roomMemberContent?.displayName @@ -113,9 +115,10 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, } // numberOfTimelineEvents++ timelineEvents.add(timelineEventEntity) + return timelineEventEntity } -private fun computeIsUnique( +fun computeIsUnique( realm: Realm, roomId: String, isLastForward: Boolean, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt index 724f307e3bda3648bf01b53cb11e5dd7c4f7bb80..9ad2708b4381ae456234042efb6c4c4b9b373b0f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt @@ -18,9 +18,16 @@ package org.matrix.android.sdk.internal.database.helper import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity internal fun RoomEntity.addIfNecessary(chunkEntity: ChunkEntity) { if (!chunks.contains(chunkEntity)) { chunks.add(chunkEntity) } } + +internal fun RoomEntity.addIfNecessary(threadSummary: ThreadSummaryEntity) { + if (!threadSummaries.contains(threadSummary)) { + threadSummaries.add(threadSummary) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt index f703bfaf82adcac49a4c96697ae90b099ac7267d..04cf5b78af79e8dc4d3994782ac11768d6a55580 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt @@ -16,9 +16,12 @@ package org.matrix.android.sdk.internal.database.helper +import com.squareup.moshi.JsonDataException import io.realm.Realm import io.realm.RealmQuery import io.realm.Sort +import org.matrix.android.sdk.api.session.events.model.UnsignedData +import org.matrix.android.sdk.api.session.events.model.isRedacted import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.internal.database.mapper.asDomain @@ -33,8 +36,10 @@ import org.matrix.android.sdk.internal.database.query.findIncludingEvent import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.whereRoomId +import org.matrix.android.sdk.internal.di.MoshiProvider +import timber.log.Timber -private typealias ThreadSummary = Pair<Int, TimelineEventEntity>? +private typealias Summary = Pair<Int, TimelineEventEntity>? /** * Finds the root thread event and update it with the latest message summary along with the number @@ -48,14 +53,14 @@ internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded( for ((rootThreadEventId, eventEntity) in this) { eventEntity.threadSummaryInThread(eventEntity.realm, rootThreadEventId, chunkEntity)?.let { threadSummary -> - val numberOfMessages = threadSummary.first + val inThreadMessages = threadSummary.first val latestEventInThread = threadSummary.second // If this is a thread message, find its root event if exists val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity rootThreadEvent?.markEventAsRoot( - threadsCounted = numberOfMessages, + inThreadMessages = inThreadMessages, latestMessageTimelineEventEntity = latestEventInThread ) } @@ -81,27 +86,27 @@ internal fun EventEntity.findRootThreadEvent(): EventEntity? = * Mark or update the current event a root thread event */ internal fun EventEntity.markEventAsRoot( - threadsCounted: Int, + inThreadMessages: Int, latestMessageTimelineEventEntity: TimelineEventEntity?) { isRootThread = true - numberOfThreads = threadsCounted + numberOfThreads = inThreadMessages threadSummaryLatestMessage = latestMessageTimelineEventEntity } /** * Count the number of threads for the provided root thread eventId, and finds the latest event message + * note: Redactions are handled by RedactionEventProcessor * @param rootThreadEventId The root eventId that will find the number of threads * @return A ThreadSummary containing the counted threads and the latest event message */ -internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary { - // Number of messages - val messages = TimelineEventEntity - .whereRoomId(realm, roomId = roomId) - .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) - .count() - .toInt() +internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): Summary { + val inThreadMessages = countInThreadMessages( + realm = realm, + roomId = roomId, + rootThreadEventId = rootThreadEventId + ) - if (messages <= 0) return null + if (inThreadMessages <= 0) return null // Find latest thread event, we know it exists var chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) ?: chunkEntity ?: return null @@ -123,9 +128,38 @@ internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: result ?: return null - return ThreadSummary(messages, result) + return Summary(inThreadMessages, result) } +/** + * Counts the number of thread replies in the main timeline thread summary, + * with respect to redactions. + */ +internal fun countInThreadMessages(realm: Realm, roomId: String, rootThreadEventId: String): Int = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .distinct(TimelineEventEntityFields.ROOT.EVENT_ID) + .findAll() + .filterNot { timelineEvent -> + timelineEvent.root + ?.unsignedData + ?.takeIf { it.isNotBlank() } + ?.toUnsignedData() + .isRedacted() + }.size + +/** + * Mapping string to UnsignedData using Moshi + */ +private fun String.toUnsignedData(): UnsignedData? = + try { + MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).fromJson(this) + } catch (ex: JsonDataException) { + Timber.e(ex, "Failed to parse UnsignedData") + null + } + /** * Lets compare them in case user is moving forward in the timeline and we cannot know the * exact chunk sequence while currentChunk is not yet committed in the DB @@ -156,6 +190,7 @@ internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm, TimelineEventEntity .whereRoomId(realm, roomId = roomId) .equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) + .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false) .sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING) /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt new file mode 100644 index 0000000000000000000000000000000000000000..7087f071621d2b8cc90aa99fffad2365d948d849 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt @@ -0,0 +1,328 @@ +/* + * 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.database.helper + +import io.realm.Realm +import io.realm.RealmQuery +import io.realm.Sort +import io.realm.kotlin.createObject +import org.matrix.android.sdk.api.session.crypto.CryptoService +import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType +import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.mapper.toEntity +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.getOrNull +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent +import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDecryptor +import timber.log.Timber +import java.util.UUID + +internal fun ThreadSummaryEntity.updateThreadSummary( + rootThreadEventEntity: EventEntity, + numberOfThreads: Int?, + latestThreadEventEntity: EventEntity?, + isUserParticipating: Boolean, + roomMemberContentsByUser: HashMap<String, RoomMemberContent?>) { + updateThreadSummaryRootEvent(rootThreadEventEntity, roomMemberContentsByUser) + updateThreadSummaryLatestEvent(latestThreadEventEntity, roomMemberContentsByUser) + this.isUserParticipating = isUserParticipating + numberOfThreads?.let { + // Update number of threads only when there is an actual value + this.numberOfThreads = it + } +} + +/** + * Updates the root thread event properties + */ +internal fun ThreadSummaryEntity.updateThreadSummaryRootEvent( + rootThreadEventEntity: EventEntity, + roomMemberContentsByUser: HashMap<String, RoomMemberContent?> +) { + val roomId = rootThreadEventEntity.roomId + val rootThreadRoomMemberContent = roomMemberContentsByUser[rootThreadEventEntity.sender ?: ""] + this.rootThreadEventEntity = rootThreadEventEntity + this.rootThreadSenderAvatar = rootThreadRoomMemberContent?.avatarUrl + this.rootThreadSenderName = rootThreadRoomMemberContent?.displayName + this.rootThreadIsUniqueDisplayName = if (rootThreadRoomMemberContent?.displayName != null) { + computeIsUnique(realm, roomId, false, rootThreadRoomMemberContent, roomMemberContentsByUser) + } else { + true + } +} + +/** + * Updates the latest thread event properties + */ +internal fun ThreadSummaryEntity.updateThreadSummaryLatestEvent( + latestThreadEventEntity: EventEntity?, + roomMemberContentsByUser: HashMap<String, RoomMemberContent?> +) { + val roomId = latestThreadEventEntity?.roomId ?: return + val latestThreadRoomMemberContent = roomMemberContentsByUser[latestThreadEventEntity.sender ?: ""] + this.latestThreadEventEntity = latestThreadEventEntity + this.latestThreadSenderAvatar = latestThreadRoomMemberContent?.avatarUrl + this.latestThreadSenderName = latestThreadRoomMemberContent?.displayName + this.latestThreadIsUniqueDisplayName = if (latestThreadRoomMemberContent?.displayName != null) { + computeIsUnique(realm, roomId, false, latestThreadRoomMemberContent, roomMemberContentsByUser) + } else { + true + } +} + +private fun EventEntity.toTimelineEventEntity(roomMemberContentsByUser: HashMap<String, RoomMemberContent?>): TimelineEventEntity { + val roomId = roomId + val eventId = eventId + val localId = TimelineEventEntity.nextId(realm) + val senderId = sender ?: "" + + val timelineEventEntity = realm.createObject<TimelineEventEntity>().apply { + this.localId = localId + this.root = this@toTimelineEventEntity + this.eventId = eventId + this.roomId = roomId + this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst() + ?.also { it.cleanUp(sender) } + this.ownedByThreadChunk = true // To skip it from the original event flow + val roomMemberContent = roomMemberContentsByUser[senderId] + this.senderAvatar = roomMemberContent?.avatarUrl + this.senderName = roomMemberContent?.displayName + isUniqueDisplayName = if (roomMemberContent?.displayName != null) { + computeIsUnique(realm, roomId, false, roomMemberContent, roomMemberContentsByUser) + } else { + true + } + } + return timelineEventEntity +} + +internal suspend fun ThreadSummaryEntity.Companion.createOrUpdate( + threadSummaryType: ThreadSummaryUpdateType, + realm: Realm, + roomId: String, + threadEventEntity: EventEntity? = null, + rootThreadEvent: Event? = null, + roomMemberContentsByUser: HashMap<String, RoomMemberContent?>, + roomEntity: RoomEntity, + userId: String, + cryptoService: CryptoService? = null +) { + when (threadSummaryType) { + ThreadSummaryUpdateType.REPLACE -> { + rootThreadEvent?.eventId ?: return + rootThreadEvent.senderId ?: return + + val numberOfThreads = rootThreadEvent.unsignedData?.relations?.latestThread?.count ?: return + + // Something is wrong with the server return + if (numberOfThreads <= 0) return + + val threadSummary = ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEvent.eventId).also { + Timber.i("###THREADS ThreadSummaryHelper REPLACE eventId:${it.rootThreadEventId} ") + } + + val rootThreadEventEntity = createEventEntity(roomId, rootThreadEvent, realm).also { + decryptIfNeeded(cryptoService, it, roomId) + } + val latestThreadEventEntity = createLatestEventEntity(roomId, rootThreadEvent, roomMemberContentsByUser, realm)?.also { + decryptIfNeeded(cryptoService, it, roomId) + } + val isUserParticipating = rootThreadEvent.unsignedData.relations.latestThread.isUserParticipating == true || rootThreadEvent.senderId == userId + roomMemberContentsByUser.addSenderState(realm, roomId, rootThreadEvent.senderId) + threadSummary.updateThreadSummary( + rootThreadEventEntity = rootThreadEventEntity, + numberOfThreads = numberOfThreads, + latestThreadEventEntity = latestThreadEventEntity, + isUserParticipating = isUserParticipating, + roomMemberContentsByUser = roomMemberContentsByUser + ) + + roomEntity.addIfNecessary(threadSummary) + } + ThreadSummaryUpdateType.ADD -> { + val rootThreadEventId = threadEventEntity?.rootThreadEventId ?: return + Timber.i("###THREADS ThreadSummaryHelper ADD for root eventId:$rootThreadEventId") + + val threadSummary = ThreadSummaryEntity.getOrNull(realm, roomId, rootThreadEventId) + if (threadSummary != null) { + // ThreadSummary exists so lets add the latest event + Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId exists, lets update latest thread event.") + threadSummary.updateThreadSummaryLatestEvent(threadEventEntity, roomMemberContentsByUser) + threadSummary.numberOfThreads++ + if (threadEventEntity.sender == userId) { + threadSummary.isUserParticipating = true + } + } else { + // ThreadSummary do not exists lets try to create one + Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId do not exists, lets try to create one") + threadEventEntity.findRootThreadEvent()?.let { rootThreadEventEntity -> + // Root thread event entity exists so lets create a new record + ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEventEntity.eventId).let { + it.updateThreadSummary( + rootThreadEventEntity = rootThreadEventEntity, + numberOfThreads = 1, + latestThreadEventEntity = threadEventEntity, + isUserParticipating = threadEventEntity.sender == userId, + roomMemberContentsByUser = roomMemberContentsByUser + ) + roomEntity.addIfNecessary(it) + } + } + } + } + } +} + +private suspend fun decryptIfNeeded(cryptoService: CryptoService?, eventEntity: EventEntity, roomId: String) { + cryptoService ?: return + val event = eventEntity.asDomain() + if (event.isEncrypted() && event.mxDecryptionResult == null && event.eventId != null) { + try { + Timber.i("###THREADS ThreadSummaryHelper request decryption for eventId:${event.eventId}") + // Event from sync does not have roomId, so add it to the event first + val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + // Save decryption result, to not decrypt every time we enter the thread list + eventEntity.setDecryptionResult(result) + } catch (e: MXCryptoError) { + if (e is MXCryptoError.Base) { + event.mCryptoError = e.errorType + event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription + } + } + } +} + +/** + * Request decryption + */ +private fun requestDecryption(eventDecryptor: TimelineEventDecryptor?, event: Event?) { + eventDecryptor ?: return + event ?: return + if (event.isEncrypted() && + event.mxDecryptionResult == null && event.eventId != null) { + Timber.i("###THREADS ThreadSummaryHelper request decryption for eventId:${event.eventId}") + + eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(event, UUID.randomUUID().toString())) + } +} + +/** + * If we don't have any new state on this user, get it from db + */ +private fun HashMap<String, RoomMemberContent?>.addSenderState(realm: Realm, roomId: String, senderId: String) { + getOrPut(senderId) { + CurrentStateEventEntity + .getOrNull(realm, roomId, senderId, EventType.STATE_ROOM_MEMBER) + ?.root?.asDomain() + ?.getFixedRoomMemberContent() + } +} + +/** + * Create an EventEntity for the root thread event or get an existing one + */ +private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity { + val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } + return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) +} + +/** + * Create an EventEntity for the latest thread event or get an existing one. Also update the user room member + * state + */ +private fun createLatestEventEntity( + roomId: String, + rootThreadEvent: Event, + roomMemberContentsByUser: HashMap<String, RoomMemberContent?>, + realm: Realm): EventEntity? { + return getLatestEvent(rootThreadEvent)?.let { + it.senderId?.let { senderId -> + roomMemberContentsByUser.addSenderState(realm, roomId, senderId) + } + createEventEntity(roomId, it, realm) + } +} + +/** + * Returned the latest event message, if any + */ +private fun getLatestEvent(rootThreadEvent: Event): Event? { + return rootThreadEvent.unsignedData?.relations?.latestThread?.event +} + +/** + * Find all ThreadSummaryEntity for the specified roomId, sorted by origin server + * note: Sorting cannot be provided by server, so we have to use that unstable property + * @param roomId The id of the room + */ +internal fun ThreadSummaryEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery<ThreadSummaryEntity> = + ThreadSummaryEntity + .where(realm, roomId = roomId) + .sort(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.ORIGIN_SERVER_TS, Sort.DESCENDING) + +/** + * Enhance each [ThreadSummary] root and latest event with the equivalent decrypted text edition/replacement + */ +internal fun List<ThreadSummary>.enhanceWithEditions(realm: Realm, roomId: String): List<ThreadSummary> = + this.map { + it.addEditionIfNeeded(realm, roomId, true) + it.addEditionIfNeeded(realm, roomId, false) + it + } + +private fun ThreadSummary.addEditionIfNeeded(realm: Realm, roomId: String, enhanceRoot: Boolean) { + val eventId = if (enhanceRoot) rootEventId else latestEvent?.eventId ?: return + EventAnnotationsSummaryEntity + .where(realm, roomId, eventId) + .findFirst() + ?.editSummary + ?.editions + ?.lastOrNull() + ?.eventId + ?.let { editedEventId -> + TimelineEventEntity.where(realm, roomId, eventId = editedEventId).findFirst()?.let { editedEvent -> + if (enhanceRoot) { + threadEditions.rootThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() ?: "(edited)" + } else { + threadEditions.latestThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() ?: "(edited)" + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt index 700b94a98582b1600e25b9b18d7678099c50c7a2..069e539e2c04505ace0b66743c8cfd12d9069de9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt @@ -19,15 +19,19 @@ package org.matrix.android.sdk.internal.database.lightweight import android.content.Context import androidx.core.content.edit import androidx.preference.PreferenceManager +import org.matrix.android.sdk.api.MatrixConfiguration import javax.inject.Inject /** * The purpose of this class is to provide an alternative and lightweight way to store settings/data - * on the sdi without using the database. This should be used just for sdk/user preferences and + * on the sdk without using the database. This should be used just for sdk/user preferences and * not for large data sets */ -class LightweightSettingsStorage @Inject constructor(context: Context) { +class LightweightSettingsStorage @Inject constructor( + context: Context, + private val matrixConfiguration: MatrixConfiguration +) { private val sdkDefaultPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) @@ -38,7 +42,7 @@ class LightweightSettingsStorage @Inject constructor(context: Context) { } fun areThreadMessagesEnabled(): Boolean { - return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, false) + return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, matrixConfiguration.threadMessagesEnabledDefault) } companion object { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt index 9c420e81fd70759fdaf70876d43e79fe755daddd..c3302f5ccbe9b52d4fcc844b209d3c6ad4732d63 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt @@ -114,7 +114,7 @@ internal object EventMapper { ) }, threadNotificationState = eventEntity.threadNotificationState, - threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(), + threadSummaryLatestEvent = eventEntity.threadSummaryLatestMessage?.root?.asDomain(), lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt index 7869506015e282845a4dab84bd17ed19f6a1fd2c..2e33988a22b853f941add399e3da3464928e2417 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt @@ -41,7 +41,8 @@ internal object HomeServerCapabilitiesMapper { maxUploadFileSize = entity.maxUploadFileSize, lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported, defaultIdentityServerUrl = entity.defaultIdentityServerUrl, - roomVersions = mapRoomVersion(entity.roomVersionsJson) + roomVersions = mapRoomVersion(entity.roomVersionsJson), + canUseThreading = entity.canUseThreading ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..cedb9e3d452a409dad32fffe8e14d9a9209c35f2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt @@ -0,0 +1,48 @@ +/* + * 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.database.mapper + +import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import javax.inject.Inject + +internal class ThreadSummaryMapper @Inject constructor() { + + fun map(threadSummary: ThreadSummaryEntity): ThreadSummary { + return ThreadSummary( + roomId = threadSummary.room?.firstOrNull()?.roomId.orEmpty(), + rootEvent = threadSummary.rootThreadEventEntity?.asDomain(), + latestEvent = threadSummary.latestThreadEventEntity?.asDomain(), + rootEventId = threadSummary.rootThreadEventId.orEmpty(), + rootThreadSenderInfo = SenderInfo( + userId = threadSummary.rootThreadEventEntity?.sender ?: "", + displayName = threadSummary.rootThreadSenderName, + isUniqueDisplayName = threadSummary.rootThreadIsUniqueDisplayName, + avatarUrl = threadSummary.rootThreadSenderAvatar + ), + latestThreadSenderInfo = SenderInfo( + userId = threadSummary.latestThreadEventEntity?.sender ?: "", + displayName = threadSummary.latestThreadSenderName, + isUniqueDisplayName = threadSummary.latestThreadIsUniqueDisplayName, + avatarUrl = threadSummary.latestThreadSenderAvatar + ), + isUserParticipating = threadSummary.isUserParticipating, + numberOfThreads = threadSummary.numberOfThreads + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt index 55c7f2a8ee605a7283e54bf0d66622275ae3e4c4..e6586224444cd7de1c5aa938d13e8d7059910bc1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt @@ -46,6 +46,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, avatarUrl = timelineEventEntity.senderAvatar ), + ownedByThreadChunk = timelineEventEntity.ownedByThreadChunk, readReceipts = readReceipts ?.distinctBy { it.roomMember diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt new file mode 100644 index 0000000000000000000000000000000000000000..f108a91ecf01c6f36a656cb3fc54e753bddbac38 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 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.database.migration + +import io.realm.DynamicRealm +import io.realm.FieldAttribute +import org.matrix.android.sdk.internal.database.model.ChunkEntityFields +import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields +import org.matrix.android.sdk.internal.database.model.RoomEntityFields +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields +import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +/** + * Migrating to: + * Live thread list: using enhanced /messages api MSC3440 + * Live thread timeline: using /relations api + */ +class MigrateSessionTo026(realm: DynamicRealm) : RealmMigrator(realm, 26) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("ChunkEntity") + ?.addField(ChunkEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED) + ?.addField(ChunkEntityFields.IS_LAST_FORWARD_THREAD, Boolean::class.java, FieldAttribute.INDEXED) + + realm.schema.get("TimelineEventEntity") + ?.addField(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, Boolean::class.java) + + val eventEntity = realm.schema.get("EventEntity") ?: return + val threadSummaryEntity = realm.schema.create("ThreadSummaryEntity") + .addField(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED) + .addField(ThreadSummaryEntityFields.ROOT_THREAD_SENDER_NAME, String::class.java) + .addField(ThreadSummaryEntityFields.ROOT_THREAD_SENDER_AVATAR, String::class.java) + .addField(ThreadSummaryEntityFields.ROOT_THREAD_IS_UNIQUE_DISPLAY_NAME, Boolean::class.java) + .addField(ThreadSummaryEntityFields.LATEST_THREAD_SENDER_NAME, String::class.java) + .addField(ThreadSummaryEntityFields.LATEST_THREAD_SENDER_AVATAR, String::class.java) + .addField(ThreadSummaryEntityFields.LATEST_THREAD_IS_UNIQUE_DISPLAY_NAME, Boolean::class.java) + .addField(ThreadSummaryEntityFields.NUMBER_OF_THREADS, Int::class.java) + .addField(ThreadSummaryEntityFields.IS_USER_PARTICIPATING, Boolean::class.java) + .addRealmObjectField(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ENTITY.`$`, eventEntity) + .addRealmObjectField(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.`$`, eventEntity) + + realm.schema.get("RoomEntity") + ?.addRealmListField(RoomEntityFields.THREAD_SUMMARIES.`$`, threadSummaryEntity) + + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField(HomeServerCapabilitiesEntityFields.CAN_USE_THREADING, Boolean::class.java) + ?.forceRefreshOfHomeServerCapabilities() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt index c45c27ed08a0fe2f361ae17d3ccf64b1d4384ac0..88eb821aa9d9088a2241a30d0309cc30a3983dcf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt @@ -33,7 +33,10 @@ internal open class ChunkEntity(@Index var prevToken: String? = null, var timelineEvents: RealmList<TimelineEventEntity> = RealmList(), // Only one chunk will have isLastForward == true @Index var isLastForward: Boolean = false, - @Index var isLastBackward: Boolean = false + @Index var isLastBackward: Boolean = false, + // Threads + @Index var rootThreadEventId: String? = null, + @Index var isLastForwardThread: Boolean = false, ) : RealmObject() { fun identifier() = "${prevToken}_$nextToken" @@ -47,14 +50,32 @@ internal open class ChunkEntity(@Index var prevToken: String? = null, companion object } -internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRoot: Boolean) { +internal fun ChunkEntity.deleteOnCascade( + deleteStateEvents: Boolean, + canDeleteRoot: Boolean) { assertIsManaged() if (deleteStateEvents) { stateEvents.deleteAllFromRealm() } timelineEvents.clearWith { val deleteRoot = canDeleteRoot && (it.root?.stateKey == null || deleteStateEvents) + if (deleteRoot) { + room?.firstOrNull()?.removeThreadSummaryIfNeeded(it.eventId) + } it.deleteOnCascade(deleteRoot) } deleteFromRealm() } + +/** + * Delete the chunk along with the thread events that were temporarily created + */ +internal fun ChunkEntity.deleteAndClearThreadEvents() { + assertIsManaged() + timelineEvents + .filter { it.ownedByThreadChunk } + .forEach { + it.deleteOnCascade(false) + } + deleteFromRealm() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt index 445181e576416c50a287701c98cf6d2efb8bf1c9..09be98aa9609d53653bdfd995a27c2359134b62a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt @@ -34,16 +34,17 @@ internal open class EventEntity(@Index var eventId: String = "", @Index var stateKey: String? = null, var originServerTs: Long? = null, @Index var sender: String? = null, - // Can contain a serialized MatrixError + // Can contain a serialized MatrixError var sendStateDetails: String? = null, var age: Long? = 0, var unsignedData: String? = null, var redacts: String? = null, var decryptionResultJson: String? = null, var ageLocalTs: Long? = null, - // Thread related, no need to create a new Entity for performance + // Thread related, no need to create a new Entity for performance @Index var isRootThread: Boolean = false, @Index var rootThreadEventId: String? = null, + // Number messages within the thread var numberOfThreads: Int = 0, var threadSummaryLatestMessage: TimelineEventEntity? = null ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt index 08ecd5995ec490c8a14cfc35f1186b35740e2f9e..47a83f0ed998fbbd27713416a302b90bde103809 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt @@ -28,7 +28,8 @@ internal open class HomeServerCapabilitiesEntity( var maxUploadFileSize: Long = HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN, var lastVersionIdentityServerSupported: Boolean = false, var defaultIdentityServerUrl: String? = null, - var lastUpdatedTimestamp: Long = 0L + var lastUpdatedTimestamp: Long = 0L, + var canUseThreading: Boolean = false ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt index 2997d5d7d8ad4a4a1e3f1f17cb3fbefdbd50c6b3..4a6f6a7bf8970a893e6f018431db61ee57a0a7ee 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt @@ -20,10 +20,14 @@ import io.realm.RealmList import io.realm.RealmObject import io.realm.annotations.PrimaryKey import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.query.findRootOrLatest +import org.matrix.android.sdk.internal.extensions.assertIsManaged internal open class RoomEntity(@PrimaryKey var roomId: String = "", var chunks: RealmList<ChunkEntity> = RealmList(), var sendingTimelineEvents: RealmList<TimelineEventEntity> = RealmList(), + var threadSummaries: RealmList<ThreadSummaryEntity> = RealmList(), var accountData: RealmList<RoomAccountDataEntity> = RealmList() ) : RealmObject() { @@ -46,3 +50,10 @@ internal open class RoomEntity(@PrimaryKey var roomId: String = "", } companion object } +internal fun RoomEntity.removeThreadSummaryIfNeeded(eventId: String) { + assertIsManaged() + threadSummaries.findRootOrLatest(eventId)?.let { + threadSummaries.remove(it) + it.deleteFromRealm() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt index c090777972194dc6cef0f40f286bb796f6f7d1a4..d0d23dd491b3336676cd4561a184cf8d5655c17e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.database.model import io.realm.annotations.RealmModule import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity /** * Realm module for Session @@ -66,6 +67,7 @@ import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntit RoomAccountDataEntity::class, SpaceChildSummaryEntity::class, SpaceParentSummaryEntity::class, - UserPresenceEntity::class + UserPresenceEntity::class, + ThreadSummaryEntity::class ]) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt index 185f0e2dcc4bd52ff202879a0c37729c884bee8b..aacd6570bc4768ed37c7ea26e292b3f3e3051cef 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt @@ -32,6 +32,9 @@ internal open class TimelineEventEntity(var localId: Long = 0, var isUniqueDisplayName: Boolean = false, var senderAvatar: String? = null, var senderMembershipEventId: String? = null, + // ownedByThreadChunk indicates that the current TimelineEventEntity belongs + // to a thread chunk and is a temporarily event. + var ownedByThreadChunk: Boolean = false, var readReceipts: ReadReceiptsSummaryEntity? = null ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt new file mode 100644 index 0000000000000000000000000000000000000000..ab9d66548ed418a47652a8f0f4944b084869c8aa --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt @@ -0,0 +1,43 @@ +/* + * 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.database.model.threads + +import io.realm.RealmObject +import io.realm.RealmResults +import io.realm.annotations.Index +import io.realm.annotations.LinkingObjects +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.RoomEntity + +internal open class ThreadSummaryEntity(@Index var rootThreadEventId: String? = "", + var rootThreadEventEntity: EventEntity? = null, + var latestThreadEventEntity: EventEntity? = null, + var rootThreadSenderName: String? = null, + var latestThreadSenderName: String? = null, + var rootThreadSenderAvatar: String? = null, + var latestThreadSenderAvatar: String? = null, + var rootThreadIsUniqueDisplayName: Boolean = false, + var isUserParticipating: Boolean = false, + var latestThreadIsUniqueDisplayName: Boolean = false, + var numberOfThreads: Int = 0 +) : RealmObject() { + + @LinkingObjects("threadSummaries") + val room: RealmResults<RoomEntity>? = null + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt index 156a8dd767c9acd266d171a9f51365da28ea7cf4..ece46555a7e145af189912cf9a76a551aee23774 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt @@ -45,10 +45,22 @@ internal fun ChunkEntity.Companion.findLastForwardChunkOfRoom(realm: Realm, room .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) .findFirst() } - +internal fun ChunkEntity.Companion.findLastForwardChunkOfThread(realm: Realm, roomId: String, rootThreadEventId: String): ChunkEntity? { + return where(realm, roomId) + .equalTo(ChunkEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true) + .findFirst() +} +internal fun ChunkEntity.Companion.findEventInThreadChunk(realm: Realm, roomId: String, event: String): ChunkEntity? { + return where(realm, roomId) + .`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, arrayListOf(event).toTypedArray()) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true) + .findFirst() +} internal fun ChunkEntity.Companion.findAllIncludingEvents(realm: Realm, eventIds: List<String>): RealmResults<ChunkEntity> { return realm.where<ChunkEntity>() .`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, eventIds.toTypedArray()) + .isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID) .findAll() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt index 14cb7e22da2dabc9ee2132a8efdb7ea4c4e25078..6caa832110ccee65587a7420a8b5f97f796b0d7c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt @@ -34,7 +34,7 @@ internal fun EventAnnotationsSummaryEntity.Companion.create(realm: Realm, roomId this.roomId = roomId } // Denormalization - TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findFirst()?.let { + TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findAll()?.forEach { it.annotations = obj } return obj diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomSummaryEntityQueries.kt index d1b05a4932c2dc70e7028dec264a82fe292b230f..8993c36a300f1d41b7c510b2eb06d3e315ca9f1e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomSummaryEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomSummaryEntityQueries.kt @@ -49,6 +49,10 @@ internal fun RoomSummaryEntity.Companion.getOrCreate(realm: Realm, roomId: Strin return where(realm, roomId).findFirst() ?: realm.createObject(roomId) } +internal fun RoomSummaryEntity.Companion.getOrNull(realm: Realm, roomId: String): RoomSummaryEntity? { + return where(realm, roomId).findFirst() +} + internal fun RoomSummaryEntity.Companion.getDirectRooms(realm: Realm, excludeRoomIds: Set<String>? = null): RealmResults<RoomSummaryEntity> { return RoomSummaryEntity.where(realm) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt new file mode 100644 index 0000000000000000000000000000000000000000..517d43d7cf965efe4593be5144d3028e45a2ddfe --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt @@ -0,0 +1,59 @@ +/* + * 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.database.query + +import io.realm.Realm +import io.realm.RealmList +import io.realm.RealmQuery +import io.realm.kotlin.createObject +import io.realm.kotlin.where +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields + +internal fun ThreadSummaryEntity.Companion.where(realm: Realm, roomId: String): RealmQuery<ThreadSummaryEntity> { + return realm.where<ThreadSummaryEntity>() + .equalTo(ThreadSummaryEntityFields.ROOM.ROOM_ID, roomId) +} + +internal fun ThreadSummaryEntity.Companion.where(realm: Realm, roomId: String, rootThreadEventId: String): RealmQuery<ThreadSummaryEntity> { + return where(realm, roomId) + .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) +} + +internal fun ThreadSummaryEntity.Companion.getOrCreate(realm: Realm, roomId: String, rootThreadEventId: String): ThreadSummaryEntity { + return where(realm, roomId, rootThreadEventId).findFirst() ?: realm.createObject<ThreadSummaryEntity>().apply { + this.rootThreadEventId = rootThreadEventId + } +} +internal fun ThreadSummaryEntity.Companion.getOrNull(realm: Realm, roomId: String, rootThreadEventId: String): ThreadSummaryEntity? { + return where(realm, roomId, rootThreadEventId).findFirst() +} +internal fun RealmList<ThreadSummaryEntity>.find(rootThreadEventId: String): ThreadSummaryEntity? { + return this.where() + .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .findFirst() +} + +internal fun RealmList<ThreadSummaryEntity>.findRootOrLatest(eventId: String): ThreadSummaryEntity? { + return this.where() + .beginGroup() + .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, eventId) + .or() + .equalTo(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.EVENT_ID, eventId) + .endGroup() + .findFirst() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt index 63f41ebf2cf47473104e34192f759bf254b1e1fc..81d5ac835f1465b7249a786ab224a3e409eab6cd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt @@ -97,6 +97,7 @@ internal fun RealmQuery<TimelineEventEntity>.filterEvents(filters: TimelineEvent if (filters.filterEdits) { not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.EDIT) not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.RESPONSE) + not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.REFERENCE) } if (filters.filterRedacted) { not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt index 10a0d1dcec01f4bb8dbcd82b7dc86ee62bb1d109..a7317506a0aafbf7e3d05eafb0f2dd1f365d85a0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt @@ -26,6 +26,7 @@ internal object TimelineEventFilter { internal object Content { internal const val EDIT = """{*"m.relates_to"*"rel_type":*"m.replace"*}""" internal const val RESPONSE = """{*"m.relates_to"*"rel_type":*"org.matrix.response"*}""" + internal const val REFERENCE = """{*"m.relates_to"*"rel_type":*"m.reference"*}""" } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt index 7d004bc5c075514596956145013e8f7195e7a51c..fedd7d05f90117a9a1176c6d9d7a3db7db8f8f9a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt @@ -80,8 +80,8 @@ internal class WorkManagerProvider @Inject constructor( workManager.enqueue(checkWorkerRequest) val checkWorkerLiveState = workManager.getWorkInfoByIdLiveData(checkWorkerRequest.id) val observer = object : Observer<WorkInfo> { - override fun onChanged(workInfo: WorkInfo) { - if (workInfo.state.isFinished) { + override fun onChanged(workInfo: WorkInfo?) { + if (workInfo?.state?.isFinished == true) { checkWorkerLiveState.removeObserver(this) if (workInfo.state == WorkInfo.State.FAILED) { throw RuntimeException("MatrixWorkerFactory is not being set on your worker configuration.\n" + diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/Result.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/Result.kt index 3734c5dc1daddace0130b80f91547b511dcf71f0..12adf16cbca399f68e0c770e75d3933c2dcb7c54 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/Result.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/Result.kt @@ -21,3 +21,11 @@ fun <A> Result<A>.foldToCallback(callback: MatrixCallback<A>): Unit = fold( { callback.onSuccess(it) }, { callback.onFailure(it) } ) + +@Suppress("UNCHECKED_CAST") // We're casting null failure results to R +inline fun <T, R> Result<T>.andThen(block: (T) -> Result<R>): Result<R> { + return when (val result = getOrNull()) { + null -> this as Result<R> + else -> block(result) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java index 2820b6688617a236a627f6709f959294ca94c90c..62f90f563ef456f868ef0d41d9b0cbd2ba203790 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java @@ -16,6 +16,7 @@ package org.matrix.android.sdk.internal.legacy.riot; +import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; @@ -196,6 +197,7 @@ public class LoginStorage { /** * Clear the stored values */ + @SuppressLint("ApplySharedPref") public void clear() { SharedPreferences prefs = mContext.getSharedPreferences(PREFS_LOGIN, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt index 1ab1042129f72743a1b42667723360eaf622d7eb..5aec7db66cc08848103e15bf8b11d081b20c4299 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt @@ -21,6 +21,7 @@ internal object NetworkConstants { private const val URI_API_PREFIX_PATH = "_matrix/client" const val URI_API_PREFIX_PATH_ = "$URI_API_PREFIX_PATH/" const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/" + const val URI_API_PREFIX_PATH_V1 = "$URI_API_PREFIX_PATH/v1/" const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/" // Media diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cache/DefaultCacheService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cache/DefaultCacheService.kt index 6d0cd37e1fa9c2d60b25edba7ff900967d7a4498..93b0dba13e1f7695303363b2561696bd05bc0af4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cache/DefaultCacheService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cache/DefaultCacheService.kt @@ -21,9 +21,9 @@ import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.task.TaskExecutor import javax.inject.Inject -internal class DefaultCacheService @Inject constructor(@SessionDatabase - private val clearCacheTask: ClearCacheTask, - private val taskExecutor: TaskExecutor +internal class DefaultCacheService @Inject constructor( + @SessionDatabase private val clearCacheTask: ClearCacheTask, + private val taskExecutor: TaskExecutor ) : CacheService { override suspend fun clearCache() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt index 7415b988a43eadd878a12006e35328445aea2da5..676a4f6a38cfcd9f45b546ca839d0b1fdf5845ec 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt @@ -17,9 +17,21 @@ package org.matrix.android.sdk.internal.session.filter import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import timber.log.Timber internal object FilterFactory { + fun createThreadsFilter(numberOfEvents: Int, userId: String?): RoomEventFilter { + Timber.i("$userId") + return RoomEventFilter( + limit = numberOfEvents, +// senders = listOf(userId), +// relationSenders = userId?.let { listOf(it) }, + relationTypes = listOf(RelationType.THREAD) + ) + } + fun createUploadsFilter(numberOfEvents: Int): RoomEventFilter { return RoomEventFilter( limit = numberOfEvents, @@ -58,8 +70,8 @@ internal object FilterFactory { private fun createElementTimelineFilter(): RoomEventFilter? { return null // RoomEventFilter().apply { - // TODO Enable this for optimization - // types = listOfSupportedEventTypes.toMutableList() + // TODO Enable this for optimization + // types = listOfSupportedEventTypes.toMutableList() // } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt index f49832296708f98ff7743224048411e1cbce0b1f..634ea73480a5987b70143b31acea003176ed6725 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt @@ -52,12 +52,13 @@ data class RoomEventFilter( * A list of relation types which must be exist pointing to the event being filtered. * If this list is absent then no filtering is done on relation types. */ - @Json(name = "relation_types") val relationTypes: List<String>? = null, + @Json(name = "related_by_rel_types") val relationTypes: List<String>? = null, /** * A list of senders of relations which must exist pointing to the event being filtered. * If this list is absent then no filtering is done on relation types. */ - @Json(name = "relation_senders") val relationSenders: List<String>? = null, + @Json(name = "related_by_senders") val relationSenders: List<String>? = null, + /** * A list of room IDs to include. If this list is absent then all rooms are included. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt index 830a58cd12813398d0df5efe6b1b55892947172a..55526b41db604d0126d0fe69e604bbcccfe3f5f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt @@ -65,7 +65,13 @@ internal data class Capabilities( * Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms. */ @Json(name = "m.room_versions") - val roomVersions: RoomVersions? = null + val roomVersions: RoomVersions? = null, + /** + * Capability to indicate if the server supports MSC3440 Threading + * True if the user can use m.thread relation, false otherwise + */ + @Json(name = "m.thread") + val threads: BooleanCapability? = null ) @JsonClass(generateAdapter = true) 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 e822cbdcdbf42c06fa2c282dff5b8189400cd8fd..44e13d971a84b3d52a278a1b78783c73e907e75b 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 @@ -20,9 +20,11 @@ import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.MatrixPatterns.getDomain 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 import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.internal.auth.version.Versions +import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity import org.matrix.android.sdk.internal.database.query.getOrCreate @@ -121,6 +123,8 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor( homeServerCapabilitiesEntity.roomVersionsJson = capabilities?.roomVersions?.let { MoshiProvider.providesMoshi().adapter(RoomVersions::class.java).toJson(it) } + homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */ + getVersionResult?.doesServerSupportThreads().orFalse() } if (getMediaConfigResult != null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt index 8b05d2ea629adf4db7495235d77eb1546adc5713..8ae203c2b316535342af89aac59f4e41deac80a4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt @@ -56,7 +56,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor( val allEvents = (newJoinEvents + inviteEvents).filter { event -> when (event.type) { - EventType.POLL_START, + in EventType.POLL_START, EventType.MESSAGE, EventType.REDACTION, EventType.ENCRYPTED, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt index 144ebb54042dc7774ee9d082a4577a2dade83f6f..196a8c122df8c32d45c6006d7cdb172d7b0e2a3c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt @@ -43,4 +43,8 @@ internal class DefaultPermalinkService @Inject constructor( override fun getLinkedId(url: String): String? { return permalinkFactory.getLinkedId(url) } + + override fun createMentionSpanTemplate(type: PermalinkService.SpanTemplateType, forceMatrixTo: Boolean): String { + return permalinkFactory.createMentionSpanTemplate(type, forceMatrixTo) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt index 39c1ddfdce3b569f1c5234bf29a9b0cc3642a5b6..0aeb0467de8ebf4ad3f7520448da03becb3a1baa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt @@ -21,7 +21,10 @@ import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.permalinks.PermalinkData import org.matrix.android.sdk.api.session.permalinks.PermalinkParser +import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.session.permalinks.PermalinkService.Companion.MATRIX_TO_URL_BASE +import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.HTML +import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.MARKDOWN import org.matrix.android.sdk.internal.di.UserId import javax.inject.Inject @@ -105,6 +108,23 @@ internal class PermalinkFactory @Inject constructor( ?.substringBeforeLast("?") } + fun createMentionSpanTemplate(type: PermalinkService.SpanTemplateType, forceMatrixTo: Boolean): String { + return buildString { + when (type) { + HTML -> append(MENTION_SPAN_TO_HTML_TEMPLATE_BEGIN) + MARKDOWN -> append(MENTION_SPAN_TO_MD_TEMPLATE_BEGIN) + } + append(baseUrl(forceMatrixTo)) + if (useClientFormat(forceMatrixTo)) { + append(USER_PATH) + } + when (type) { + HTML -> append(MENTION_SPAN_TO_HTML_TEMPLATE_END) + MARKDOWN -> append(MENTION_SPAN_TO_MD_TEMPLATE_END) + } + } + } + /** * Escape '/' in id, because it is used as a separator * @@ -147,5 +167,9 @@ internal class PermalinkFactory @Inject constructor( private const val ROOM_PATH = "room/" private const val USER_PATH = "user/" private const val GROUP_PATH = "group/" + private const val MENTION_SPAN_TO_HTML_TEMPLATE_BEGIN = "<a href=\"" + private const val MENTION_SPAN_TO_HTML_TEMPLATE_END = "%1\$s\">%2\$s</a>" + private const val MENTION_SPAN_TO_MD_TEMPLATE_BEGIN = "[%2\$s](" + private const val MENTION_SPAN_TO_MD_TEMPLATE_END = "%1\$s)" } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt index 2d8c3e9c78eaecd443d207259b3d0e9998127a9c..34e859e509374e7d4252ef193ce3cc58d6ddc36e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt @@ -36,6 +36,7 @@ import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService import org.matrix.android.sdk.api.session.room.threads.ThreadsService +import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -56,6 +57,7 @@ internal class DefaultRoom(override val roomId: String, private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineService: TimelineService, private val threadsService: ThreadsService, + private val threadsLocalService: ThreadsLocalService, private val sendService: SendService, private val draftService: DraftService, private val stateService: StateService, @@ -80,6 +82,7 @@ internal class DefaultRoom(override val roomId: String, Room, TimelineService by timelineService, ThreadsService by threadsService, + ThreadsLocalService by threadsLocalService, SendService by sendService, DraftService by draftService, StateService by stateService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt index 4a02c55db0fe6c007c609f8deaa89324b33ca65c..c79c41069b36dd220302ef4b66287fb920ac3ba5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.paging.PagedList import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.RoomService @@ -32,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams import org.matrix.android.sdk.api.session.room.peeking.PeekResult +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional @@ -51,6 +53,7 @@ import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryUpdater import org.matrix.android.sdk.internal.session.user.accountdata.UpdateBreadcrumbsTask import org.matrix.android.sdk.internal.util.fetchCopied import javax.inject.Inject @@ -69,6 +72,7 @@ internal class DefaultRoomService @Inject constructor( private val roomSummaryDataSource: RoomSummaryDataSource, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, private val leaveRoomTask: LeaveRoomTask, + private val roomSummaryUpdater: RoomSummaryUpdater ) : RoomService { override suspend fun createRoom(createRoomParams: CreateRoomParams): String { @@ -92,6 +96,23 @@ internal class DefaultRoomService @Inject constructor( return roomSummaryDataSource.getRoomSummaries(queryParams, sortOrder) } + override fun refreshJoinedRoomSummaryPreviews(roomId: String?) { + val roomSummaries = getRoomSummaries(roomSummaryQueryParams { + if (roomId != null) { + this.roomId = QueryStringValue.Equals(roomId) + } + memberships = listOf(Membership.JOIN) + }) + + if (roomSummaries.isNotEmpty()) { + monarchy.runTransactionSync { realm -> + roomSummaries.forEach { + roomSummaryUpdater.refreshLatestPreviewContent(realm, it.roomId) + } + } + } + } + override fun getRoomSummariesLive(queryParams: RoomSummaryQueryParams, sortOrder: RoomSortOrder): LiveData<List<RoomSummary>> { return roomSummaryDataSource.getRoomSummariesLive(queryParams, sortOrder) @@ -109,6 +130,10 @@ internal class DefaultRoomService @Inject constructor( return roomSummaryDataSource.getUpdatablePagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder) } + override fun getRoomCountLive(queryParams: RoomSummaryQueryParams): LiveData<Int> { + return roomSummaryDataSource.getCountLive(queryParams) + } + override fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount { return roomSummaryDataSource.getNotificationCountForRooms(queryParams) } 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 1e0eb8b49787921d29a5a94bbb9bbe5a04d01fac..8bbe3a9ac61e26f9508ece7b9f80587d983771f4 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 @@ -57,6 +57,7 @@ import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryE import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntityFields import org.matrix.android.sdk.internal.database.model.ReferencesAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.where @@ -86,11 +87,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor( // TODO Add ? // EventType.KEY_VERIFICATION_READY, EventType.KEY_VERIFICATION_KEY, - EventType.ENCRYPTED, - EventType.POLL_START, - EventType.POLL_RESPONSE, - EventType.POLL_END - ) + EventType.ENCRYPTED + ) + EventType.POLL_START + EventType.POLL_RESPONSE + EventType.POLL_END override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { return allowedTypes.contains(eventType) @@ -117,8 +115,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor( EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst() ?.let { - TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findFirst() - ?.let { tet -> tet.annotations = it } + TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll() + ?.forEach { tet -> tet.annotations = it } } } @@ -156,7 +154,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( Timber.v("###REPLACE in room $roomId for event ${event.eventId}") // A replace! handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) - } else if (event.getClearType() == EventType.POLL_RESPONSE) { + } else if (event.getClearType() in EventType.POLL_RESPONSE) { event.getClearContent().toModel<MessagePollResponseContent>(catchError = true)?.let { pollResponseContent -> Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") handleResponse(realm, event, pollResponseContent, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) @@ -177,12 +175,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor( handleVerification(realm, event, roomId, isLocalEcho, it) } } - EventType.POLL_RESPONSE -> { + in EventType.POLL_RESPONSE -> { event.getClearContent().toModel<MessagePollResponseContent>(catchError = true)?.let { handleResponse(realm, event, it, roomId, isLocalEcho, event.getRelationContent()?.eventId) } } - EventType.POLL_END -> { + in EventType.POLL_END -> { event.content.toModel<MessageEndPollContent>(catchError = true)?.let { handleEndPoll(realm, event, it, roomId, isLocalEcho) } @@ -196,6 +194,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( handleReaction(realm, event, roomId, isLocalEcho) } } + // HandleInitialAggregatedRelations should also be applied in encrypted messages with annotations +// else if (event.unsignedData?.relations?.annotations != null) { +// Timber.v("###REACTION e2e Aggregation in room $roomId for event ${event.eventId}") +// handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations) +// EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst() +// ?.let { +// TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll() +// ?.forEach { tet -> tet.annotations = it } +// } +// } } EventType.REDACTION -> { val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } @@ -217,7 +225,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } } } - EventType.POLL_START -> { + in EventType.POLL_START -> { val content: MessagePollContent? = event.content.toModel() if (content?.relatesTo?.type == RelationType.REPLACE) { Timber.v("###REPLACE in room $roomId for event ${event.eventId}") @@ -225,12 +233,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor( handleReplace(realm, event, content, roomId, isLocalEcho) } } - EventType.POLL_RESPONSE -> { + in EventType.POLL_RESPONSE -> { event.content.toModel<MessagePollResponseContent>(catchError = true)?.let { handleResponse(realm, event, it, roomId, isLocalEcho) } } - EventType.POLL_END -> { + in EventType.POLL_END -> { event.content.toModel<MessageEndPollContent>(catchError = true)?.let { handleEndPoll(realm, event, it, roomId, isLocalEcho) } @@ -243,7 +251,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } // OPT OUT serer aggregation until API mature enough - private val SHOULD_HANDLE_SERVER_AGREGGATION = false + private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e private fun handleReplace(realm: Realm, event: Event, @@ -335,13 +343,18 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } if (!isLocalEcho) { - val replaceEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst() + val replaceEvent = TimelineEventEntity + .where(realm, roomId, eventId) + .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false) + .findFirst() handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions) } } /** * Check if the edition is on the latest thread event, and update it accordingly + * @param editedEvent The event that will be changed + * @param replaceEvent The new event */ private fun handleThreadSummaryEdition(editedEvent: EventEntity?, replaceEvent: TimelineEventEntity?, @@ -407,12 +420,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor( return } - val option = content.response?.answers?.first() ?: return Unit.also { + val option = content.getBestResponse()?.answers?.first() ?: return Unit.also { Timber.d("## POLL Ignoring malformed response no option eventId:$eventId content: ${event.content}") } // Check if this option is in available options - if (!targetPollContent.pollCreationInfo?.answers?.map { it.id }?.contains(option).orFalse()) { + if (!targetPollContent.getBestPollCreationInfo()?.answers?.map { it.id }?.contains(option).orFalse()) { Timber.v("## POLL $targetEventId doesn't contain option $option") return } @@ -469,46 +482,39 @@ internal class EventRelationsAggregationProcessor @Inject constructor( roomId: String, isLocalEcho: Boolean) { val pollEventId = content.relatesTo?.eventId ?: return - val pollOwnerId = getPollEvent(roomId, pollEventId)?.root?.senderId val isPollOwner = pollOwnerId == event.senderId - val powerLevelsHelper = stateEventDataSource.getStateEvent(roomId, EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.NoCondition) ?.content?.toModel<PowerLevelsContent>() ?.let { PowerLevelsHelper(it) } + if (!isPollOwner && !powerLevelsHelper?.isUserAbleToRedact(event.senderId ?: "").orFalse()) { Timber.v("## Received poll.end event $pollEventId but user ${event.senderId} doesn't have enough power level in room $roomId") return } - var existing = EventAnnotationsSummaryEntity.where(realm, roomId, pollEventId).findFirst() - if (existing == null) { + var existingPoll = EventAnnotationsSummaryEntity.where(realm, roomId, pollEventId).findFirst() + if (existingPoll == null) { Timber.v("## POLL creating new relation summary for $pollEventId") - existing = EventAnnotationsSummaryEntity.create(realm, roomId, pollEventId) + existingPoll = EventAnnotationsSummaryEntity.create(realm, roomId, pollEventId) } // we have it - val existingPollSummary = existing.pollResponseSummary + val existingPollSummary = existingPoll.pollResponseSummary ?: realm.createObject(PollResponseAggregatedSummaryEntity::class.java).also { - existing.pollResponseSummary = it + existingPoll.pollResponseSummary = it } - if (existingPollSummary.closedTime != null) { - Timber.v("## Received poll.end event for already ended poll $pollEventId") - return - } - val txId = event.unsignedData?.transactionId + existingPollSummary.closedTime = event.originServerTs + // is it a remote echo? if (!isLocalEcho && existingPollSummary.sourceLocalEchoEvents.contains(txId)) { // ok it has already been managed Timber.v("## POLL Receiving remote echo of response eventId:$pollEventId") existingPollSummary.sourceLocalEchoEvents.remove(txId) existingPollSummary.sourceEvents.add(event.eventId) - return } - - existingPollSummary.closedTime = event.originServerTs } private fun getPollEvent(roomId: String, eventId: String): TimelineEvent? { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt index 399bfbd0e45b00e64fb790b2186241b4768f160f..10f75473b71e3256c85e486bcf9238a4b6bf57f2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomStrippedState import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams @@ -86,7 +87,7 @@ internal interface RoomAPI { suspend fun getRoomMessagesFrom(@Path("roomId") roomId: String, @Query("from") from: String, @Query("dir") dir: String, - @Query("limit") limit: Int, + @Query("limit") limit: Int?, @Query("filter") filter: String? ): PaginationResponse @@ -218,7 +219,6 @@ internal interface RoomAPI { /** * Paginate relations for event based in normal topological order - * * @param relationType filter for this relation type * @param eventType filter for this event type */ @@ -227,9 +227,24 @@ internal interface RoomAPI { @Path("eventId") eventId: String, @Path("relationType") relationType: String, @Path("eventType") eventType: String, + @Query("from") from: String? = null, + @Query("to") to: String? = null, @Query("limit") limit: Int? = null ): RelationsResponse + /** + * Paginate relations for thread events based in normal topological order + * @param relationType filter for this relation type + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "rooms/{roomId}/relations/{eventId}/{relationType}") + suspend fun getThreadsRelations(@Path("roomId") roomId: String, + @Path("eventId") eventId: String, + @Path("relationType") relationType: String = RelationType.THREAD, + @Query("from") from: String? = null, + @Query("to") to: String? = null, + @Query("limit") limit: Int? = null + ): RelationsResponse + /** * Join the given room. * diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt index 70c1ab4f42453412aa2a5b258b3979121bdcd966..72a3f9ab22036910d5624c0aa109fc47ab194cad 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt @@ -36,6 +36,7 @@ import org.matrix.android.sdk.internal.session.room.state.SendStateTask import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService import org.matrix.android.sdk.internal.session.room.threads.DefaultThreadsService +import org.matrix.android.sdk.internal.session.room.threads.local.DefaultThreadsLocalService import org.matrix.android.sdk.internal.session.room.timeline.DefaultTimelineService import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService @@ -52,6 +53,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineServiceFactory: DefaultTimelineService.Factory, private val threadsServiceFactory: DefaultThreadsService.Factory, + private val threadsLocalServiceFactory: DefaultThreadsLocalService.Factory, private val sendServiceFactory: DefaultSendService.Factory, private val draftServiceFactory: DefaultDraftService.Factory, private val stateServiceFactory: DefaultStateService.Factory, @@ -79,6 +81,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: roomSummaryDataSource = roomSummaryDataSource, timelineService = timelineServiceFactory.create(roomId), threadsService = threadsServiceFactory.create(roomId), + threadsLocalService = threadsLocalServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId), draftService = draftServiceFactory.create(roomId), stateService = stateServiceFactory.create(roomId), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index f831a77a5d7c72539c8f4339309b62206caa1d41..5e90076b8af24fb73116c5d52fc9af1830cd56dc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -77,7 +77,9 @@ import org.matrix.android.sdk.internal.session.room.relation.DefaultUpdateQuickR import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask +import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadSummariesTask import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask @@ -294,4 +296,7 @@ internal abstract class RoomModule { @Binds abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask + + @Binds + abstract fun bindFetchThreadSummariesTask(task: DefaultFetchThreadSummariesTask): FetchThreadSummariesTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt index ee52fe574b932ba0cf0aaac736746a51f32b5a0a..b19b8d4a6b6aaef24fdc02eb6fa7a446de7bcc88 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt @@ -21,11 +21,14 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.UnsignedData +import org.matrix.android.sdk.internal.database.helper.countInThreadMessages +import org.matrix.android.sdk.internal.database.helper.findRootThreadEvent import org.matrix.android.sdk.internal.database.mapper.ContentMapper import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity import org.matrix.android.sdk.internal.database.query.findWithSenderMembershipEvent import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.MoshiProvider @@ -71,7 +74,7 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr when (typeToPrune) { EventType.ENCRYPTED, EventType.MESSAGE, - EventType.POLL_START -> { + in EventType.POLL_START -> { Timber.d("REDACTION for message ${eventToPrune.eventId}") val unsignedData = EventMapper.map(eventToPrune).unsignedData ?: UnsignedData(null, null) @@ -89,6 +92,8 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified) eventToPrune.decryptionResultJson = null eventToPrune.decryptionErrorCode = null + + handleTimelineThreadSummaryIfNeeded(realm, eventToPrune, isLocalEcho) } // EventType.REACTION -> { // eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId) @@ -104,6 +109,39 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr } } + /** + * Invalidates the number of threads in the main timeline thread summary, + * with respect to redactions. + */ + private fun handleTimelineThreadSummaryIfNeeded( + realm: Realm, + eventToPrune: EventEntity, + isLocalEcho: Boolean, + ) { + if (eventToPrune.isThread() && !isLocalEcho) { + val roomId = eventToPrune.roomId + val rootThreadEvent = eventToPrune.findRootThreadEvent() ?: return + val rootThreadEventId = eventToPrune.rootThreadEventId ?: return + + val inThreadMessages = countInThreadMessages( + realm = realm, + roomId = roomId, + rootThreadEventId = rootThreadEventId + ) + + rootThreadEvent.numberOfThreads = inThreadMessages + if (inThreadMessages == 0) { + // We should also clear the thread summary list + rootThreadEvent.isRootThread = false + rootThreadEvent.threadSummaryLatestMessage = null + ThreadSummaryEntity + .where(realm, roomId = roomId, rootThreadEventId) + .findFirst() + ?.deleteFromRealm() + } + } + } + private fun computeAllowedKeys(type: String): List<String> { // Add filtered content, allowed keys in content depends on the event type return when (type) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index d5019aea7b842448b91636324db1720b9333511e..ab514d31c8480951f7ff567eb803562566aba1e8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -34,7 +34,6 @@ import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDataSource @@ -48,7 +47,6 @@ internal class DefaultRelationService @AssistedInject constructor( private val eventFactory: LocalEchoEventFactory, private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val fetchEditHistoryTask: FetchEditHistoryTask, - private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val timelineEventDataSource: TimelineEventDataSource, @SessionDatabase private val monarchy: Monarchy ) : RelationService { @@ -196,10 +194,6 @@ internal class DefaultRelationService @AssistedInject constructor( return eventSenderProcessor.postEvent(event) } - override suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean { - return fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(roomId, rootThreadEventId)) - } - /** * Saves the event in database as a local echo. * SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt new file mode 100644 index 0000000000000000000000000000000000000000..b596f2288ec82f849ca708b5a0a7225ea83cff78 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt @@ -0,0 +1,108 @@ +/* + * 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.relation.threads + +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType +import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.database.helper.createOrUpdate +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.session.filter.FilterFactory +import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection +import org.matrix.android.sdk.internal.session.room.timeline.PaginationResponse +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import timber.log.Timber +import javax.inject.Inject + +/*** + * This class is responsible to Fetch all the thread in the current room, + * To fetch all threads in a room, the /messages API is used with newly added filtering options. + */ +internal interface FetchThreadSummariesTask : Task<FetchThreadSummariesTask.Params, DefaultFetchThreadSummariesTask.Result> { + data class Params( + val roomId: String, + val from: String = "", + val limit: Int = 500, + val isUserParticipating: Boolean = true + ) +} + +internal class DefaultFetchThreadSummariesTask @Inject constructor( + private val roomAPI: RoomAPI, + private val globalErrorReceiver: GlobalErrorReceiver, + @SessionDatabase private val monarchy: Monarchy, + private val cryptoService: DefaultCryptoService, + @UserId private val userId: String, +) : FetchThreadSummariesTask { + + override suspend fun execute(params: FetchThreadSummariesTask.Params): Result { + val filter = FilterFactory.createThreadsFilter( + numberOfEvents = params.limit, + userId = if (params.isUserParticipating) userId else null).toJSONString() + + val response = executeRequest( + globalErrorReceiver, + canRetry = true + ) { + roomAPI.getRoomMessagesFrom(params.roomId, params.from, PaginationDirection.BACKWARDS.value, params.limit, filter) + } + + Timber.i("###THREADS DefaultFetchThreadSummariesTask Fetched size:${response.events.size} nextBatch:${response.end} ") + + return handleResponse(response, params) + } + + private suspend fun handleResponse(response: PaginationResponse, + params: FetchThreadSummariesTask.Params): Result { + val rootThreadList = response.events + monarchy.awaitTransaction { realm -> + val roomEntity = RoomEntity.where(realm, roomId = params.roomId).findFirst() ?: return@awaitTransaction + + val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>() + for (rootThreadEvent in rootThreadList) { + if (rootThreadEvent.eventId == null || rootThreadEvent.senderId == null || rootThreadEvent.type == null) { + continue + } + + ThreadSummaryEntity.createOrUpdate( + threadSummaryType = ThreadSummaryUpdateType.REPLACE, + realm = realm, + roomId = params.roomId, + rootThreadEvent = rootThreadEvent, + roomMemberContentsByUser = roomMemberContentsByUser, + roomEntity = roomEntity, + userId = userId, + cryptoService = cryptoService) + } + } + return Result.SUCCESS + } + + enum class Result { + SHOULD_FETCH_MORE, + REACHED_END, + SUCCESS + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt index e0d501c515f7ea3ec595227d813c9d62a0f5fa6a..a46bbe8d9f8b76e1111db920b08f05b1b8737940 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Matrix.org Foundation C.I.C. + * 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. @@ -20,14 +20,12 @@ import io.realm.Realm import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider import org.matrix.android.sdk.internal.crypto.DefaultCryptoService import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.database.helper.addTimelineEvent -import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity @@ -36,8 +34,10 @@ import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEnt import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore -import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.find +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.where @@ -47,16 +47,38 @@ import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.util.awaitTransaction import timber.log.Timber import javax.inject.Inject -internal interface FetchThreadTimelineTask : Task<FetchThreadTimelineTask.Params, Boolean> { +/*** + * This class is responsible to Fetch paginated chunks of the thread timeline using the /relations API + * + * How it works + * + * The problem? + * - We cannot use the existing timeline architecture to paginate through the timeline + * - We want our new events to be live, so any interactions with them like reactions will continue to work. We should + * handle appropriately the existing events from /messages api with the new events from /relations. + * - Handling edge cases like receiving an event from /messages while you have already created a new one from the /relations response + * + * The solution + * We generate a temporarily thread chunk that will be used to store any new paginated results from the /relations api + * We bind the timeline events from that chunk with the already existing ones. So we will have one common instance, and + * all reactions, edits etc will continue to work. If the events do not exists we create them + * and we will reuse the same EventEntity instance when (and if) the same event will be fetched from the main (/messages) timeline + * + */ +internal interface FetchThreadTimelineTask : Task<FetchThreadTimelineTask.Params, DefaultFetchThreadTimelineTask.Result> { data class Params( val roomId: String, - val rootThreadEventId: String + val rootThreadEventId: String, + val from: String?, + val limit: Int + ) } @@ -69,94 +91,130 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor( private val cryptoService: DefaultCryptoService ) : FetchThreadTimelineTask { - override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean { - val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId) + enum class Result { + SHOULD_FETCH_MORE, + REACHED_END, + SUCCESS + } + + override suspend fun execute(params: FetchThreadTimelineTask.Params): Result { val response = executeRequest(globalErrorReceiver) { - roomAPI.getRelations( + roomAPI.getThreadsRelations( roomId = params.roomId, eventId = params.rootThreadEventId, - relationType = RelationType.IO_THREAD, - eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE, - limit = 2000 + from = params.from, + limit = params.limit ) } - val threadList = response.chunks + listOfNotNull(response.originalEvent) - - return storeNewEventsIfNeeded(threadList, params.roomId) + Timber.i("###THREADS FetchThreadTimelineTask Fetched size:${response.chunks.size} nextBatch:${response.nextBatch} ") + return handleRelationsResponse(response, params) } - /** - * Store new events if they are not already received, and returns weather or not, - * a timeline update should be made - * @param threadList is the list containing the thread replies - * @param roomId the roomId of the the thread - * @return - */ - private suspend fun storeNewEventsIfNeeded(threadList: List<Event>, roomId: String): Boolean { - var eventsSkipped = 0 - monarchy - .awaitTransaction { realm -> - val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) - - val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>() - val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>() - - for (event in threadList.reversed()) { - if (event.eventId == null || event.senderId == null || event.type == null) { - eventsSkipped++ - continue - } - - if (EventEntity.where(realm, event.eventId).findFirst() != null) { - // Skip if event already exists - eventsSkipped++ - continue - } - if (event.isEncrypted()) { - // Decrypt events that will be stored - decryptIfNeeded(event, roomId) - } - - handleReaction(realm, event, roomId) - - val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC) - - // Sender info - roomMemberContentsByUser.getOrPut(event.senderId) { - // If we don't have any new state on this user, get it from db - val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root - rootStateEvent?.asDomain()?.getFixedRoomMemberContent() - } - - chunk?.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) - eventEntity.rootThreadEventId?.let { - // This is a thread event - optimizedThreadSummaryMap[it] = eventEntity - } ?: run { - // This is a normal event or a root thread one - optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity - } + private suspend fun handleRelationsResponse(response: RelationsResponse, + params: FetchThreadTimelineTask.Params): Result { + val threadList = response.chunks + val threadRootEvent = response.originalEvent + val hasReachEnd = response.nextBatch == null + + monarchy.awaitTransaction { realm -> + + val threadChunk = ChunkEntity.findLastForwardChunkOfThread(realm, params.roomId, params.rootThreadEventId) + ?: run { + return@awaitTransaction } - optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( - roomId = roomId, - realm = realm, - currentUserId = userId, - shouldUpdateNotifications = false - ) + threadChunk.prevToken = response.nextBatch + val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>() + + for (event in threadList) { + if (event.eventId == null || event.senderId == null || event.type == null) { + continue + } + + if (threadChunk.timelineEvents.find(event.eventId) != null) { + // Event already exists in thread chunk, skip it + Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} already exists in thread chunk, skip it") + continue + } + + val timelineEvent = TimelineEventEntity + .where(realm, roomId = params.roomId, event.eventId) + .findFirst() + + if (timelineEvent != null) { + // Event already exists but not in the thread chunk + // Lets added there + Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} exists but not in the thread chunk, add it at the end") + threadChunk.timelineEvents.add(timelineEvent) + } else { + Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} is brand NEW create an entity and add it!") + val eventEntity = createEventEntity(params.roomId, event, realm) + roomMemberContentsByUser.addSenderState(realm, params.roomId, event.senderId) + threadChunk.addTimelineEvent( + roomId = params.roomId, + eventEntity = eventEntity, + direction = PaginationDirection.FORWARDS, + ownedByThreadChunk = true, + roomMemberContentsByUser = roomMemberContentsByUser) } - Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}") + } - return eventsSkipped == threadList.size + if (hasReachEnd) { + val rootThread = TimelineEventEntity + .where(realm, roomId = params.roomId, params.rootThreadEventId) + .findFirst() + if (rootThread != null) { + // If root thread event already exists add it to our chunk + threadChunk.timelineEvents.add(rootThread) + Timber.i("###THREADS FetchThreadTimelineTask root thread event: ${params.rootThreadEventId} found and added!") + } else if (threadRootEvent?.senderId != null) { + // Case when thread event is not in the device + Timber.i("###THREADS FetchThreadTimelineTask root thread event: ${params.rootThreadEventId} NOT FOUND! Lets create a temp one") + val eventEntity = createEventEntity(params.roomId, threadRootEvent, realm) + roomMemberContentsByUser.addSenderState(realm, params.roomId, threadRootEvent.senderId) + threadChunk.addTimelineEvent( + roomId = params.roomId, + eventEntity = eventEntity, + direction = PaginationDirection.FORWARDS, + ownedByThreadChunk = true, + roomMemberContentsByUser = roomMemberContentsByUser) + } + } + } + + return if (hasReachEnd) { + Result.REACHED_END + } else { + Result.SHOULD_FETCH_MORE + } } + // TODO Reuse this function to all the app /** - * Invoke the event decryption mechanism for a specific event + * If we don't have any new state on this user, get it from db */ + private fun HashMap<String, RoomMemberContent?>.addSenderState(realm: Realm, roomId: String, senderId: String) { + getOrPut(senderId) { + CurrentStateEventEntity + .getOrNull(realm, roomId, senderId, EventType.STATE_ROOM_MEMBER) + ?.root?.asDomain() + ?.getFixedRoomMemberContent() + } + } - private fun decryptIfNeeded(event: Event, roomId: String) { + /** + * Create an EventEntity to be added in the TimelineEventEntity + */ + private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity { + val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } + return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) + } + + /** + * Invoke the event decryption mechanism for a specific event + */ + private suspend fun decryptIfNeeded(event: Event, roomId: String) { try { // Event from sync does not have roomId, so add it to the event first val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 28c17f38b6a51cf881d1d19c6aea439f246d5fd6..31c7254ed5ee77a3f443f6248f3a8087746e2bc0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -128,8 +128,8 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable { - return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty) + override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable { + return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty, isUserLocation) .also { createLocalEcho(it) } .let { sendEvent(it) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 3c36d587107c55df72f09177a2cbafc6233359fd..0ba95cc1fb834f5426cd0ce481d95cfd4dce5968 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -137,16 +137,11 @@ internal class LocalEchoEventFactory @Inject constructor( options: List<String>, pollType: PollType): MessagePollContent { return MessagePollContent( - pollCreationInfo = PollCreationInfo( - question = PollQuestion( - question = question - ), + unstablePollCreationInfo = PollCreationInfo( + question = PollQuestion(unstableQuestion = question), kind = pollType, answers = options.map { option -> - PollAnswer( - id = UUID.randomUUID().toString(), - answer = option - ) + PollAnswer(id = UUID.randomUUID().toString(), unstableAnswer = option) } ) ) @@ -167,7 +162,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_START, + type = EventType.POLL_START.first(), content = newContent.toContent() ) } @@ -179,11 +174,9 @@ internal class LocalEchoEventFactory @Inject constructor( body = answerId, relatesTo = RelationDefaultContent( type = RelationType.REFERENCE, - eventId = pollEventId), - response = PollResponse( - answers = listOf(answerId) - ) - + eventId = pollEventId + ), + unstableResponse = PollResponse(answers = listOf(answerId)) ) val localId = LocalEcho.createLocalEchoId() return Event( @@ -191,7 +184,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_RESPONSE, + type = EventType.POLL_RESPONSE.first(), content = content.toContent(), unsignedData = UnsignedData(age = null, transactionId = localId)) } @@ -207,7 +200,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_START, + type = EventType.POLL_START.first(), content = content.toContent(), unsignedData = UnsignedData(age = null, transactionId = localId)) } @@ -226,7 +219,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_END, + type = EventType.POLL_END.first(), content = content.toContent(), unsignedData = UnsignedData(age = null, transactionId = localId)) } @@ -234,20 +227,17 @@ internal class LocalEchoEventFactory @Inject constructor( fun createLocationEvent(roomId: String, latitude: Double, longitude: Double, - uncertainty: Double?): Event { + uncertainty: Double?, + isUserLocation: Boolean): Event { val geoUri = buildGeoUri(latitude, longitude, uncertainty) + val assetType = if (isUserLocation) LocationAssetType.SELF else LocationAssetType.PIN val content = MessageLocationContent( geoUri = geoUri, body = geoUri, - locationInfo = LocationInfo( - geoUri = geoUri, - description = geoUri - ), - locationAsset = LocationAsset( - type = LocationAssetType.SELF - ), - ts = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()), - text = geoUri + unstableLocationInfo = LocationInfo(geoUri = geoUri, description = geoUri), + unstableLocationAsset = LocationAsset(type = assetType), + unstableTs = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()), + unstableText = geoUri ) return createMessageEvent(roomId, content) } @@ -353,8 +343,9 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -396,8 +387,9 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -426,8 +418,9 @@ internal class LocalEchoEventFactory @Inject constructor( voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -446,8 +439,9 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -479,7 +473,7 @@ internal class LocalEchoEventFactory @Inject constructor( private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? { var newContent: Content? = null if (type == EventType.STICKER) { - val isThread = (content.toModel<MessageStickerContent>())?.relatesTo?.type == RelationType.IO_THREAD + val isThread = (content.toModel<MessageStickerContent>())?.relatesTo?.type == RelationType.THREAD val rootThreadEventId = (content.toModel<MessageStickerContent>())?.relatesTo?.eventId if (isThread && rootThreadEventId != null) { val newRelationalDefaultContent = (content.toModel<MessageStickerContent>())?.relatesTo?.copy( @@ -560,7 +554,7 @@ internal class LocalEchoEventFactory @Inject constructor( relatesTo = generateReplyRelationContent( eventId = eventId, rootThreadEventId = rootThreadEventId, - showAsReply = showInThread)) + showInThread = showInThread)) return createMessageEvent(roomId, content) } @@ -570,18 +564,20 @@ internal class LocalEchoEventFactory @Inject constructor( * "m.relates_to": { * "rel_type": "m.thread", * "event_id": "$thread_root", + * "is_falling_back": false, * "m.in_reply_to": { - * "event_id": "$event_target", - * "render_in": ["m.thread"] - * } - * } + * "event_id": "$event_target" + * } + * } */ - private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showAsReply: Boolean): RelationDefaultContent = + private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showInThread: Boolean): RelationDefaultContent = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, - inReplyTo = ReplyToContent(eventId = eventId, renderIn = if (showAsReply) arrayListOf("m.thread") else null)) + isFallingBack = showInThread, + // False when is a rich reply from within a thread, and true when is a reply that should be visible from threads + inReplyTo = ReplyToContent(eventId = eventId)) } ?: RelationDefaultContent(null, null, ReplyToContent(eventId = eventId)) private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String { @@ -638,7 +634,9 @@ internal class LocalEchoEventFactory @Inject constructor( MessageType.MSGTYPE_AUDIO -> return TextContent("sent an audio file.") MessageType.MSGTYPE_IMAGE -> return TextContent("sent an image.") MessageType.MSGTYPE_VIDEO -> return TextContent("sent a video.") - MessageType.MSGTYPE_POLL_START -> return TextContent((content as? MessagePollContent)?.pollCreationInfo?.question?.question ?: "") + MessageType.MSGTYPE_POLL_START -> { + return TextContent((content as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "") + } else -> return TextContent(content?.body ?: "") } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt index 5c629f87f0e31608e79b6a6ee7a98dfcf2bf354d..93c0167abe8eeadf5822117dcf8bed0edd324c70 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt @@ -58,8 +58,9 @@ fun TextContent.toThreadTextContent( format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null }, body = text, relatesTo = RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = rootThreadEventId, + isFallingBack = true, inReplyTo = ReplyToContent( eventId = latestThreadEventId )), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt index ccbfbfcded2ab238efe4fff13ee6f84f93b81bf5..fa2e0052abd8c197a934da65cb366074c438d2be 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.internal.session.room.send.pills import android.text.SpannableString +import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.internal.session.displayname.DisplayNameResolver @@ -28,7 +29,8 @@ import javax.inject.Inject */ internal class TextPillsUtils @Inject constructor( private val mentionLinkSpecComparator: MentionLinkSpecComparator, - private val displayNameResolver: DisplayNameResolver + private val displayNameResolver: DisplayNameResolver, + private val permalinkService: PermalinkService ) { /** @@ -36,7 +38,7 @@ internal class TextPillsUtils @Inject constructor( * @return the transformed String or null if no Span found */ fun processSpecialSpansToHtml(text: CharSequence): String? { - return transformPills(text, MENTION_SPAN_TO_HTML_TEMPLATE) + return transformPills(text, permalinkService.createMentionSpanTemplate(PermalinkService.SpanTemplateType.HTML)) } /** @@ -44,7 +46,7 @@ internal class TextPillsUtils @Inject constructor( * @return the transformed String or null if no Span found */ fun processSpecialSpansToMarkdown(text: CharSequence): String? { - return transformPills(text, MENTION_SPAN_TO_MD_TEMPLATE) + return transformPills(text, permalinkService.createMentionSpanTemplate(PermalinkService.SpanTemplateType.MARKDOWN)) } private fun transformPills(text: CharSequence, template: String): String? { @@ -108,10 +110,4 @@ internal class TextPillsUtils @Inject constructor( i++ } } - - companion object { - private const val MENTION_SPAN_TO_HTML_TEMPLATE = "<a href=\"https://matrix.to/#/%1\$s\">%2\$s</a>" - - private const val MENTION_SPAN_TO_MD_TEMPLATE = "[%2\$s](https://matrix.to/#/%1\$s)" - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt index c9fc3c9575be324d9efcb63c07147fbc51ae6835..18a4f80547cb23e23914a686d466b8967bd0994c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt @@ -26,6 +26,7 @@ import com.zhuinden.monarchy.Monarchy import io.realm.Realm import io.realm.RealmQuery import io.realm.kotlin.where +import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.RoomCategoryFilter import org.matrix.android.sdk.api.query.isNormalized @@ -42,6 +43,7 @@ import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotification import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional +import org.matrix.android.sdk.internal.database.RealmSessionProvider import org.matrix.android.sdk.internal.database.mapper.RoomSummaryMapper import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields @@ -55,8 +57,10 @@ import javax.inject.Inject internal class RoomSummaryDataSource @Inject constructor( @SessionDatabase private val monarchy: Monarchy, + private val realmSessionProvider: RealmSessionProvider, private val roomSummaryMapper: RoomSummaryMapper, - private val queryStringValueProcessor: QueryStringValueProcessor + private val queryStringValueProcessor: QueryStringValueProcessor, + private val coroutineDispatchers: MatrixCoroutineDispatchers ) { fun getRoomSummary(roomIdOrAlias: String): RoomSummary? { @@ -219,14 +223,25 @@ internal class RoomSummaryDataSource @Inject constructor( return object : UpdatableLivePageResult { override val livePagedList: LiveData<PagedList<RoomSummary>> = mapped - override fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) { - realmDataSourceFactory.updateQuery { - roomSummariesQuery(it, builder.invoke(queryParams)).process(sortOrder) - } - } - override val liveBoundaries: LiveData<ResultBoundaries> get() = boundaries + + override var queryParams: RoomSummaryQueryParams = queryParams + set(value) { + field = value + realmDataSourceFactory.updateQuery { + roomSummariesQuery(it, value).process(sortOrder) + } + } + } + } + + fun getCountLive(queryParams: RoomSummaryQueryParams): LiveData<Int> { + val liveRooms = monarchy.findAllManagedWithChanges { + roomSummariesQuery(it, queryParams) + } + return Transformations.map(liveRooms) { + it.realmResults.where().count().toInt() } } @@ -293,6 +308,7 @@ internal class RoomSummaryDataSource @Inject constructor( RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false) RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS -> query.greaterThan(RoomSummaryEntityFields.NOTIFICATION_COUNT, 0) RoomCategoryFilter.ALL -> Unit // nop + null -> Unit } // Timber.w("VAL: activeSpaceId : ${queryParams.activeSpaceId}") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index 1c1d59fb3d23f119b0d5d87426adf1244b156bb1..c9d84b1b9357f4c631e2e2699cfd497caad98c1b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.summary import io.realm.Realm import io.realm.kotlin.createObject +import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.events.model.EventType @@ -63,7 +64,6 @@ import org.matrix.android.sdk.internal.session.room.accountdata.RoomAccountDataD import org.matrix.android.sdk.internal.session.room.membership.RoomDisplayNameResolver import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper import org.matrix.android.sdk.internal.session.room.relationship.RoomChildRelationInfo -import org.matrix.android.sdk.internal.util.Normalizer import timber.log.Timber import javax.inject.Inject import kotlin.system.measureTimeMillis @@ -74,8 +74,16 @@ internal class RoomSummaryUpdater @Inject constructor( private val roomAvatarResolver: RoomAvatarResolver, private val eventDecryptor: EventDecryptor, private val crossSigningService: DefaultCrossSigningService, - private val roomAccountDataDataSource: RoomAccountDataDataSource, - private val normalizer: Normalizer) { + private val roomAccountDataDataSource: RoomAccountDataDataSource +) { + + fun refreshLatestPreviewContent(realm: Realm, roomId: String) { + val roomSummaryEntity = RoomSummaryEntity.getOrNull(realm, roomId) + if (roomSummaryEntity != null) { + val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) + latestPreviewableEvent?.attemptToDecrypt() + } + } fun update(realm: Realm, roomId: String, @@ -127,6 +135,7 @@ internal class RoomSummaryUpdater @Inject constructor( val lastActivityFromEvent = latestPreviewableEvent?.root?.originServerTs if (lastActivityFromEvent != null) { roomSummaryEntity.lastActivityTime = lastActivityFromEvent + latestPreviewableEvent.attemptToDecrypt() } roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0 || @@ -160,16 +169,6 @@ internal class RoomSummaryUpdater @Inject constructor( } roomSummaryEntity.updateHasFailedSending() - val root = latestPreviewableEvent?.root - if (root?.type == EventType.ENCRYPTED && root.decryptionResultJson == null) { - Timber.v("Should decrypt ${latestPreviewableEvent.eventId}") - // mmm i want to decrypt now or is it ok to do it async? - tryOrNull { - eventDecryptor.decryptEvent(root.asDomain(), "") - } - ?.let { root.setDecryptionResult(it) } - } - if (updateMembers) { val otherRoomMembers = RoomMemberHelper(realm, roomId) .queryActiveRoomMembersEvent() @@ -186,6 +185,22 @@ internal class RoomSummaryUpdater @Inject constructor( } } + private fun TimelineEventEntity.attemptToDecrypt() { + when (val root = this.root) { + null -> { + Timber.v("Decryption skipped due to missing root event $eventId") + } + else -> { + if (root.type == EventType.ENCRYPTED && root.decryptionResultJson == null) { + Timber.v("Should decrypt $eventId") + tryOrNull { + runBlocking { eventDecryptor.decryptEvent(root.asDomain(), "") } + }?.let { root.setDecryptionResult(it) } + } + } + } + } + private fun RoomSummaryEntity.updateHasFailedSending() { hasFailedSending = TimelineEventEntity.findAllInRoomWithSendStates(realm, roomId, SendState.HAS_FAILED_STATES).isNotEmpty() } @@ -411,8 +426,6 @@ internal class RoomSummaryUpdater @Inject constructor( realm.where(RoomSummaryEntity::class.java) .process(RoomSummaryEntityFields.MEMBERSHIP_STR, listOf(Membership.JOIN)) .notEqualTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE) - // also we do not count DM in here, because home space will already show them - .equalTo(RoomSummaryEntityFields.IS_DIRECT, false) .contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, space.roomId) .findAll().forEach { highlightCount += it.highlightCount diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt index 5967ae8d2edd2a11d7653fe52ea95565ccd75364..b65991347dd3693edab907d8c211243e3c72c544 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt @@ -23,25 +23,25 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.realm.Realm import org.matrix.android.sdk.api.session.room.threads.ThreadsService -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.threads.ThreadNotificationState -import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.internal.database.helper.enhanceWithEditions import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId -import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread -import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition +import org.matrix.android.sdk.internal.database.mapper.ThreadSummaryMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper -import org.matrix.android.sdk.internal.database.model.EventEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntity -import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.UserId -import org.matrix.android.sdk.internal.util.awaitTransaction +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask internal class DefaultThreadsService @AssistedInject constructor( @Assisted private val roomId: String, @UserId private val userId: String, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, + private val fetchThreadSummariesTask: FetchThreadSummariesTask, @SessionDatabase private val monarchy: Monarchy, private val timelineEventMapper: TimelineEventMapper, + private val threadSummaryMapper: ThreadSummaryMapper ) : ThreadsService { @AssistedFactory @@ -49,55 +49,40 @@ internal class DefaultThreadsService @AssistedInject constructor( fun create(roomId: String): DefaultThreadsService } - override fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>> { + override fun getAllThreadSummariesLive(): LiveData<List<ThreadSummary>> { return monarchy.findAllMappedWithChanges( - { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } + { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { + threadSummaryMapper.map(it) + } ) } - override fun getMarkedThreadNotifications(): List<TimelineEvent> { + override fun getAllThreadSummaries(): List<ThreadSummary> { return monarchy.fetchAllMappedSync( - { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } + { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { threadSummaryMapper.map(it) } ) } - override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> { - return monarchy.findAllMappedWithChanges( - { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } - ) - } - - override fun getAllThreads(): List<TimelineEvent> { - return monarchy.fetchAllMappedSync( - { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } - ) - } - - override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean { + override fun enhanceThreadWithEditions(threads: List<ThreadSummary>): List<ThreadSummary> { return Realm.getInstance(monarchy.realmConfiguration).use { - TimelineEventEntity.isUserParticipatingInThread( - realm = it, - roomId = roomId, - rootThreadEventId = rootThreadEventId, - senderId = userId) + threads.enhanceWithEditions(it, roomId) } } - override fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> { - return Realm.getInstance(monarchy.realmConfiguration).use { - threads.mapEventsWithEdition(it, roomId) - } + override suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int) { + fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params( + roomId = roomId, + rootThreadEventId = rootThreadEventId, + from = from, + limit = limit + )) } - override suspend fun markThreadAsRead(rootThreadEventId: String) { - monarchy.awaitTransaction { - EventEntity.where( - realm = it, - eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE - } + override suspend fun fetchThreadSummaries() { + fetchThreadSummariesTask.execute(FetchThreadSummariesTask.Params( + roomId = roomId + )) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt new file mode 100644 index 0000000000000000000000000000000000000000..3bc36fb2a804c8b9dfc0452a1f63d5a3e774dfbf --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt @@ -0,0 +1,103 @@ +/* + * 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.threads.local + +import androidx.lifecycle.LiveData +import com.zhuinden.monarchy.Monarchy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.realm.Realm +import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId +import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId +import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread +import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.util.awaitTransaction + +internal class DefaultThreadsLocalService @AssistedInject constructor( + @Assisted private val roomId: String, + @UserId private val userId: String, + @SessionDatabase private val monarchy: Monarchy, + private val timelineEventMapper: TimelineEventMapper, +) : ThreadsLocalService { + + @AssistedFactory + interface Factory { + fun create(roomId: String): DefaultThreadsLocalService + } + + override fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getMarkedThreadNotifications(): List<TimelineEvent> { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreads(): List<TimelineEvent> { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean { + return Realm.getInstance(monarchy.realmConfiguration).use { + TimelineEventEntity.isUserParticipatingInThread( + realm = it, + roomId = roomId, + rootThreadEventId = rootThreadEventId, + senderId = userId) + } + } + + override fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> { + return Realm.getInstance(monarchy.realmConfiguration).use { + threads.mapEventsWithEdition(it, roomId) + } + } + + override suspend fun markThreadAsRead(rootThreadEventId: String) { + monarchy.awaitTransaction { + EventEntity.where( + realm = it, + eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index 3dd4225b2c3fdee5e12c4062ff875f61d70294bf..8c2b4d2bbe2cf4327276846e52889277f7573792 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -38,6 +38,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer @@ -58,6 +59,7 @@ internal class DefaultTimeline(private val roomId: String, paginationTask: PaginationTask, getEventTask: GetContextOfEventTask, fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, + fetchThreadTimelineTask: FetchThreadTimelineTask, timelineEventMapper: TimelineEventMapper, timelineInput: TimelineInput, threadsAwarenessHandler: ThreadsAwarenessHandler, @@ -89,7 +91,9 @@ internal class DefaultTimeline(private val roomId: String, realm = backgroundRealm, eventDecryptor = eventDecryptor, paginationTask = paginationTask, + realmConfiguration = realmConfiguration, fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, + fetchThreadTimelineTask = fetchThreadTimelineTask, getContextOfEventTask = getEventTask, timelineInput = timelineInput, timelineEventMapper = timelineEventMapper, @@ -297,7 +301,13 @@ internal class DefaultTimeline(private val roomId: String, Timber.v("Post snapshot of ${snapshot.size} events") withContext(coroutineDispatchers.main) { listeners.forEach { - tryOrNull { it.onTimelineUpdated(snapshot) } + if (initialEventId != null && isFromThreadTimeline && snapshot.firstOrNull { it.eventId == initialEventId } == null) { + // We are in a thread timeline with a permalink, post update timeline only when the appropriate message have been found + tryOrNull { it.onTimelineUpdated(arrayListOf()) } + } else { + // In all the other cases update timeline as expected + tryOrNull { it.onTimelineUpdated(snapshot) } + } } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index 8094fee504ff535d9b78aa152e8e7b2c0eb5c50d..1ba2aff191ed0afacb72d24ac64b556044d43e75 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -31,6 +31,7 @@ import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsS import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler @@ -42,6 +43,7 @@ internal class DefaultTimelineService @AssistedInject constructor( private val eventDecryptor: TimelineEventDecryptor, private val paginationTask: PaginationTask, private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val timelineEventMapper: TimelineEventMapper, private val loadRoomMembersTask: LoadRoomMembersTask, private val threadsAwarenessHandler: ThreadsAwarenessHandler, @@ -64,10 +66,11 @@ internal class DefaultTimelineService @AssistedInject constructor( realmConfiguration = monarchy.realmConfiguration, coroutineDispatchers = coroutineDispatchers, paginationTask = paginationTask, + fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, timelineEventMapper = timelineEventMapper, timelineInput = timelineInput, eventDecryptor = eventDecryptor, - fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, + fetchThreadTimelineTask = fetchThreadTimelineTask, loadRoomMembersTask = loadRoomMembersTask, readReceiptHandler = readReceiptHandler, getEventTask = contextOfEventTask, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt index f332c4a35f608fd367b7c8d02b200ac331899ca9..a9e7b3bcdc43a293e08083a026fde7f2722308eb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt @@ -19,20 +19,28 @@ package org.matrix.android.sdk.internal.session.room.timeline import io.realm.OrderedCollectionChangeSet import io.realm.OrderedRealmCollectionChangeListener import io.realm.Realm +import io.realm.RealmConfiguration import io.realm.RealmResults +import io.realm.kotlin.createObject import kotlinx.coroutines.CompletableDeferred import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntityFields +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.deleteAndClearThreadEvents import org.matrix.android.sdk.internal.database.query.findAllIncludingEvents +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler +import timber.log.Timber import java.util.concurrent.atomic.AtomicReference /** @@ -76,6 +84,8 @@ internal class LoadTimelineStrategy( val realm: AtomicReference<Realm>, val eventDecryptor: TimelineEventDecryptor, val paginationTask: PaginationTask, + val realmConfiguration: RealmConfiguration, + val fetchThreadTimelineTask: FetchThreadTimelineTask, val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, val getContextOfEventTask: GetContextOfEventTask, val timelineInput: TimelineInput, @@ -90,7 +100,6 @@ internal class LoadTimelineStrategy( private var getContextLatch: CompletableDeferred<Unit>? = null private var chunkEntity: RealmResults<ChunkEntity>? = null private var timelineChunk: TimelineChunk? = null - private val chunkEntityListener = OrderedRealmCollectionChangeListener { _: RealmResults<ChunkEntity>, changeSet: OrderedCollectionChangeSet -> // Can be call either when you open a permalink on an unknown event // or when there is a gap in the timeline. @@ -170,6 +179,9 @@ internal class LoadTimelineStrategy( getContextLatch?.cancel() chunkEntity = null timelineChunk = null + if (mode is Mode.Thread) { + clearThreadChunkEntity(dependencies.realm.get(), mode.rootThreadEventId) + } } suspend fun loadMore(count: Int, direction: Timeline.Direction, fetchOnServerIfNeeded: Boolean = true): LoadMoreResult { @@ -185,6 +197,9 @@ internal class LoadTimelineStrategy( return LoadMoreResult.FAILURE } } + if (mode is Mode.Thread) { + return timelineChunk?.loadMoreThread(count) ?: LoadMoreResult.FAILURE + } return timelineChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE } @@ -201,7 +216,7 @@ internal class LoadTimelineStrategy( } private fun buildSendingEvents(): List<TimelineEvent> { - return if (hasReachedLastForward()) { + return if (hasReachedLastForward() || mode is Mode.Thread) { sendingEventsDataSource.buildSendingEvents() } else { emptyList() @@ -219,13 +234,47 @@ internal class LoadTimelineStrategy( ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) } is Mode.Thread -> { + recreateThreadChunkEntity(realm, mode.rootThreadEventId) ChunkEntity.where(realm, roomId) - .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) + .equalTo(ChunkEntityFields.ROOT_THREAD_EVENT_ID, mode.rootThreadEventId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true) .findAll() } } } + /** + * Clear any existing thread chunk entity and create a new one, with the + * rootThreadEventId included + */ + private fun recreateThreadChunkEntity(realm: Realm, rootThreadEventId: String) { + realm.executeTransaction { + // Lets delete the chunk and start a new one + ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let { + Timber.i("###THREADS LoadTimelineStrategy [onStart] thread chunk cleared..") + } + val threadChunk = it.createObject<ChunkEntity>().apply { + Timber.i("###THREADS LoadTimelineStrategy [onStart] Created new thread chunk with rootThreadEventId: $rootThreadEventId") + this.rootThreadEventId = rootThreadEventId + this.isLastForwardThread = true + } + if (threadChunk.isValid) { + RoomEntity.where(it, roomId).findFirst()?.addIfNecessary(threadChunk) + } + } + } + + /** + * Clear any existing thread chunk + */ + private fun clearThreadChunkEntity(realm: Realm, rootThreadEventId: String) { + realm.executeTransaction { + ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let { + Timber.i("###THREADS LoadTimelineStrategy [onStop] thread chunk cleared..") + } + } + } + private fun hasReachedLastForward(): Boolean { return timelineChunk?.hasReachedLastForward().orFalse() } @@ -237,8 +286,10 @@ internal class LoadTimelineStrategy( timelineSettings = dependencies.timelineSettings, roomId = roomId, timelineId = timelineId, + fetchThreadTimelineTask = dependencies.fetchThreadTimelineTask, eventDecryptor = dependencies.eventDecryptor, paginationTask = dependencies.paginationTask, + realmConfiguration = dependencies.realmConfiguration, fetchTokenAndPaginateTask = dependencies.fetchTokenAndPaginateTask, timelineEventMapper = dependencies.timelineEventMapper, uiEchoManager = uiEchoManager, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/SendingEventsDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/SendingEventsDataSource.kt index 1262c09d974b1667baaa1c0291e59596857a5b3c..637267a9b17c3c5ff066998a130286e43cdd9a92 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/SendingEventsDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/SendingEventsDataSource.kt @@ -45,9 +45,11 @@ internal class RealmSendingEventsDataSource( private var frozenSendingTimelineEvents: RealmList<TimelineEventEntity>? = null private val sendingTimelineEventsListener = RealmChangeListener<RealmList<TimelineEventEntity>> { events -> - uiEchoManager.onSentEventsInDatabase(events.map { it.eventId }) - updateFrozenResults(events) - onEventsUpdated(false) + if (events.isValid) { + uiEchoManager.onSentEventsInDatabase(events.map { it.eventId }) + updateFrozenResults(events) + onEventsUpdated(false) + } } override fun start() { @@ -55,6 +57,7 @@ internal class RealmSendingEventsDataSource( roomEntity = RoomEntity.where(safeRealm, roomId = roomId).findFirst() sendingTimelineEvents = roomEntity?.sendingTimelineEvents sendingTimelineEvents?.addChangeListener(sendingTimelineEventsListener) + updateFrozenResults(sendingTimelineEvents) } override fun stop() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt index c0dc31fcf8a988fec4c068ccdd0afc05354cc8d0..c8f2132ae66bf3e869dbedc7a74ea03892d3f3b4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.timeline import io.realm.OrderedCollectionChangeSet import io.realm.OrderedRealmCollectionChangeListener +import io.realm.RealmConfiguration import io.realm.RealmObjectChangeListener import io.realm.RealmQuery import io.realm.RealmResults @@ -36,6 +37,8 @@ import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntityFields import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler import timber.log.Timber import java.util.Collections @@ -50,8 +53,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, private val timelineSettings: TimelineSettings, private val roomId: String, private val timelineId: String, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val eventDecryptor: TimelineEventDecryptor, private val paginationTask: PaginationTask, + private val realmConfiguration: RealmConfiguration, private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, private val timelineEventMapper: TimelineEventMapper, private val uiEchoManager: UIEchoManager? = null, @@ -78,11 +83,15 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, isLastBackward.set(chunkEntity.isLastBackward) } if (changeSet.isFieldChanged(ChunkEntityFields.NEXT_CHUNK.`$`)) { - nextChunk = createTimelineChunk(chunkEntity.nextChunk) + nextChunk = createTimelineChunk(chunkEntity.nextChunk).also { + it?.prevChunk = this + } nextChunkLatch?.complete(Unit) } if (changeSet.isFieldChanged(ChunkEntityFields.PREV_CHUNK.`$`)) { - prevChunk = createTimelineChunk(chunkEntity.prevChunk) + prevChunk = createTimelineChunk(chunkEntity.prevChunk).also { + it?.nextChunk = this + } prevChunkLatch?.complete(Unit) } } @@ -141,29 +150,57 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, val loadFromStorage = loadFromStorage(count, direction).also { logLoadedFromStorage(it, direction) } + if (loadFromStorage.numberOfEvents == 6) { + Timber.i("here") + } val offsetCount = count - loadFromStorage.numberOfEvents - return if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) { + return if (offsetCount == 0) { + LoadMoreResult.SUCCESS + } else if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) { LoadMoreResult.REACHED_END } else if (direction == Timeline.Direction.BACKWARDS && isLastBackward.get()) { LoadMoreResult.REACHED_END } else if (timelineSettings.isThreadTimeline() && loadFromStorage.threadReachedEnd) { LoadMoreResult.REACHED_END - } else if (offsetCount == 0) { - LoadMoreResult.SUCCESS } else { delegateLoadMore(fetchOnServerIfNeeded, offsetCount, direction) } } + /** + * This function will fetch more live thread timeline events using the /relations api. It will + * always fetch results, while we want our data to be up to dated. + */ + suspend fun loadMoreThread(count: Int, direction: Timeline.Direction = Timeline.Direction.BACKWARDS): LoadMoreResult { + val rootThreadEventId = timelineSettings.rootThreadEventId ?: return LoadMoreResult.FAILURE + return if (direction == Timeline.Direction.BACKWARDS) { + try { + fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params( + roomId, + rootThreadEventId, + chunkEntity.prevToken, + count + )).toLoadMoreResult() + } catch (failure: Throwable) { + Timber.e(failure, "Failed to fetch thread timeline events from the server") + LoadMoreResult.FAILURE + } + } else { + LoadMoreResult.FAILURE + } + } + private suspend fun delegateLoadMore(fetchFromServerIfNeeded: Boolean, offsetCount: Int, direction: Timeline.Direction): LoadMoreResult { return if (direction == Timeline.Direction.FORWARDS) { val nextChunkEntity = chunkEntity.nextChunk when { nextChunkEntity != null -> { if (nextChunk == null) { - nextChunk = createTimelineChunk(nextChunkEntity) + nextChunk = createTimelineChunk(nextChunkEntity).also { + it?.prevChunk = this + } } nextChunk?.loadMore(offsetCount, direction, fetchFromServerIfNeeded) ?: LoadMoreResult.FAILURE } @@ -179,7 +216,9 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, when { prevChunkEntity != null -> { if (prevChunk == null) { - prevChunk = createTimelineChunk(prevChunkEntity) + prevChunk = createTimelineChunk(prevChunkEntity).also { + it?.nextChunk = this + } } prevChunk?.loadMore(offsetCount, direction, fetchFromServerIfNeeded) ?: LoadMoreResult.FAILURE } @@ -413,6 +452,14 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } } + private fun DefaultFetchThreadTimelineTask.Result.toLoadMoreResult(): LoadMoreResult { + return when (this) { + DefaultFetchThreadTimelineTask.Result.REACHED_END -> LoadMoreResult.REACHED_END + DefaultFetchThreadTimelineTask.Result.SHOULD_FETCH_MORE, + DefaultFetchThreadTimelineTask.Result.SUCCESS -> LoadMoreResult.SUCCESS + } + } + private fun getOffsetIndex(): Int { var offset = 0 var currentNextChunk = nextChunk @@ -454,6 +501,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } } } + if (insertions.isNotEmpty() || modifications.isNotEmpty()) { onBuiltEvents(true) } @@ -487,6 +535,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, timelineId = timelineId, eventDecryptor = eventDecryptor, paginationTask = paginationTask, + realmConfiguration = realmConfiguration, + fetchThreadTimelineTask = fetchThreadTimelineTask, fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, timelineEventMapper = timelineEventMapper, uiEchoManager = uiEchoManager, @@ -508,13 +558,18 @@ private fun RealmQuery<TimelineEventEntity>.offsets( count: Int, startDisplayIndex: Int ): RealmQuery<TimelineEventEntity> { - sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) - if (direction == Timeline.Direction.BACKWARDS) { + return if (direction == Timeline.Direction.BACKWARDS) { lessThanOrEqualTo(TimelineEventEntityFields.DISPLAY_INDEX, startDisplayIndex) + sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + limit(count.toLong()) } else { greaterThanOrEqualTo(TimelineEventEntityFields.DISPLAY_INDEX, startDisplayIndex) + // We need to sort ascending first so limit works in the right direction + sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) + limit(count.toLong()) + // Result is expected to be sorted descending + sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) } - return limit(count.toLong()) } private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { @@ -533,7 +588,6 @@ private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmR .or() .equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId) .endGroup() - .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) .findAll() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt index 49a8a8b55a33279e3a5abd98c6707b7eaf96d84b..3ddd877b78936b8332f78e6e5e509dee06fde42a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.timeline import io.realm.Realm import io.realm.RealmConfiguration +import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.Event @@ -99,7 +100,13 @@ internal class TimelineEventDecryptor @Inject constructor( } executor?.execute { Realm.getInstance(realmConfiguration).use { realm -> - processDecryptRequest(request, realm) + try { + runBlocking { + processDecryptRequest(request, realm) + } + } catch (e: InterruptedException) { + Timber.i("Decryption got interrupted") + } } } } @@ -115,7 +122,7 @@ internal class TimelineEventDecryptor @Inject constructor( threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity) } } - private fun processDecryptRequest(request: DecryptionRequest, realm: Realm) { + private suspend fun processDecryptRequest(request: DecryptionRequest, realm: Realm) { val event = request.event val timelineId = request.timelineId diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index 6607e71bd9cb530bc96ef00b673b7e602ebaf174..63383a99b3c961ab414590164bb124fc6c85f9e7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.find @@ -49,10 +50,10 @@ import javax.inject.Inject * Insert Chunk in DB, and eventually link next and previous chunk in db. */ internal class TokenChunkEventPersistor @Inject constructor( - @SessionDatabase private val monarchy: Monarchy, - @UserId private val userId: String, - private val lightweightSettingsStorage: LightweightSettingsStorage, - private val liveEventManager: Lazy<StreamEventsManager>) { + @SessionDatabase private val monarchy: Monarchy, + @UserId private val userId: String, + private val lightweightSettingsStorage: LightweightSettingsStorage, + private val liveEventManager: Lazy<StreamEventsManager>) { enum class Result { SHOULD_FETCH_MORE, @@ -145,9 +146,12 @@ internal class TokenChunkEventPersistor @Inject constructor( if (event.eventId == null || event.senderId == null) { return@forEach } - // We check for the timeline event with this id + // We check for the timeline event with this id, but not in the thread chunk val eventId = event.eventId - val existingTimelineEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst() + val existingTimelineEvent = TimelineEventEntity + .where(realm, roomId, eventId) + .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false) + .findFirst() // If it exists, we want to stop here, just link the prevChunk val existingChunk = existingTimelineEvent?.chunk?.firstOrNull() if (existingChunk != null) { @@ -173,7 +177,7 @@ internal class TokenChunkEventPersistor @Inject constructor( return@processTimelineEvents } val ageLocalTs = event.unsignedData?.age?.let { now - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) + var eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) { val contentToUse = if (direction == PaginationDirection.BACKWARDS) { event.prevContent @@ -183,7 +187,11 @@ internal class TokenChunkEventPersistor @Inject constructor( roomMemberContentsByUser[event.stateKey] = contentToUse.toModel<RoomMemberContent>() } liveEventManager.get().dispatchPaginatedEventReceived(event, roomId) - currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) + currentChunk.addTimelineEvent( + roomId = roomId, + eventEntity = eventEntity, + direction = direction, + roomMemberContentsByUser = roomMemberContentsByUser) if (lightweightSettingsStorage.areThreadMessagesEnabled()) { eventEntity.rootThreadEventId?.let { // This is a thread event diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt index c18055e0894f9f8f3e44ac64d2175347cd7a47b3..e764ab551ae90f7bca5a5ff69a3f863f523bb19f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt @@ -113,71 +113,108 @@ internal class DefaultSpaceService @Inject constructor( return peekSpaceTask.execute(PeekSpaceTask.Params(spaceId)) } - override suspend fun querySpaceChildren(spaceId: String, - suggestedOnly: Boolean?, - limit: Int?, - from: String?, - knownStateList: List<Event>?): SpaceHierarchyData { - return resolveSpaceInfoTask.execute( - ResolveSpaceInfoTask.Params( - spaceId = spaceId, limit = limit, maxDepth = 1, from = from, suggestedOnly = suggestedOnly - ) - ).let { response -> - val spaceDesc = response.rooms?.firstOrNull { it.roomId == spaceId } - val root = RoomSummary( - roomId = spaceDesc?.roomId ?: spaceId, - roomType = spaceDesc?.roomType, - name = spaceDesc?.name ?: "", - displayName = spaceDesc?.name ?: "", - topic = spaceDesc?.topic ?: "", - joinedMembersCount = spaceDesc?.numJoinedMembers, - avatarUrl = spaceDesc?.avatarUrl ?: "", - encryptionEventTs = null, - typingUsers = emptyList(), - isEncrypted = false, - flattenParentIds = emptyList(), - canonicalAlias = spaceDesc?.canonicalAlias, - joinRules = RoomJoinRules.PUBLIC.takeIf { spaceDesc?.worldReadable == true } - ) - val children = response.rooms - ?.filter { it.roomId != spaceId } - ?.flatMap { childSummary -> - (spaceDesc?.childrenState ?: knownStateList) - ?.filter { it.stateKey == childSummary.roomId && it.type == EventType.STATE_SPACE_CHILD } - ?.mapNotNull { childStateEv -> - // create a child entry for everytime this room is the child of a space - // beware that a room could appear then twice in this list - childStateEv.content.toModel<SpaceChildContent>()?.let { childStateEvContent -> - SpaceChildInfo( - childRoomId = childSummary.roomId, - isKnown = true, - roomType = childSummary.roomType, - name = childSummary.name, - topic = childSummary.topic, - avatarUrl = childSummary.avatarUrl, - order = childStateEvContent.order, -// autoJoin = childStateEvContent.autoJoin ?: false, - viaServers = childStateEvContent.via.orEmpty(), - activeMemberCount = childSummary.numJoinedMembers, - parentRoomId = childStateEv.roomId, - suggested = childStateEvContent.suggested, - canonicalAlias = childSummary.canonicalAlias, - aliases = childSummary.aliases, - worldReadable = childSummary.worldReadable - ) - } - }.orEmpty() - } - .orEmpty() - SpaceHierarchyData( - rootSummary = root, - children = children, - childrenState = spaceDesc?.childrenState.orEmpty(), - nextToken = response.nextBatch + override suspend fun querySpaceChildren( + spaceId: String, + suggestedOnly: Boolean?, + limit: Int?, + from: String?, + knownStateList: List<Event>? + ): SpaceHierarchyData { + val spacesResponse = getSpacesResponse(spaceId, suggestedOnly, limit, from) + val spaceRootResponse = spacesResponse.getRoot(spaceId) + val spaceRoot = spaceRootResponse?.toRoomSummary() ?: createBlankRoomSummary(spaceId) + val spaceChildren = spacesResponse.rooms.mapSpaceChildren(spaceId, spaceRootResponse, knownStateList) + + return SpaceHierarchyData( + rootSummary = spaceRoot, + children = spaceChildren, + childrenState = spaceRootResponse?.childrenState.orEmpty(), + nextToken = spacesResponse.nextBatch + ) + } + + private suspend fun getSpacesResponse(spaceId: String, suggestedOnly: Boolean?, limit: Int?, from: String?) = + resolveSpaceInfoTask.execute( + ResolveSpaceInfoTask.Params(spaceId = spaceId, limit = limit, maxDepth = 1, from = from, suggestedOnly = suggestedOnly) ) - } + + private fun SpacesResponse.getRoot(spaceId: String) = rooms?.firstOrNull { it.roomId == spaceId } + + private fun SpaceChildSummaryResponse.toRoomSummary() = RoomSummary( + roomId = roomId, + roomType = roomType, + name = name ?: "", + displayName = name ?: "", + topic = topic ?: "", + joinedMembersCount = numJoinedMembers, + avatarUrl = avatarUrl ?: "", + encryptionEventTs = null, + typingUsers = emptyList(), + isEncrypted = false, + flattenParentIds = emptyList(), + canonicalAlias = canonicalAlias, + joinRules = RoomJoinRules.PUBLIC.takeIf { isWorldReadable } + ) + + private fun createBlankRoomSummary(spaceId: String) = RoomSummary( + roomId = spaceId, + joinedMembersCount = null, + encryptionEventTs = null, + typingUsers = emptyList(), + isEncrypted = false, + flattenParentIds = emptyList(), + canonicalAlias = null, + joinRules = null + ) + + private fun List<SpaceChildSummaryResponse>?.mapSpaceChildren( + spaceId: String, + spaceRootResponse: SpaceChildSummaryResponse?, + knownStateList: List<Event>?, + ) = this?.filterIdIsNot(spaceId) + ?.toSpaceChildInfoList(spaceId, spaceRootResponse, knownStateList) + .orEmpty() + + private fun List<SpaceChildSummaryResponse>.filterIdIsNot(spaceId: String) = filter { it.roomId != spaceId } + + private fun List<SpaceChildSummaryResponse>.toSpaceChildInfoList( + spaceId: String, + rootRoomResponse: SpaceChildSummaryResponse?, + knownStateList: List<Event>?, + ) = flatMap { spaceChildSummary -> + (rootRoomResponse?.childrenState ?: knownStateList) + ?.filter { it.isChildOf(spaceChildSummary) } + ?.mapNotNull { childStateEvent -> childStateEvent.toSpaceChildInfo(spaceId, spaceChildSummary) } + .orEmpty() } + private fun Event.isChildOf(space: SpaceChildSummaryResponse) = stateKey == space.roomId && type == EventType.STATE_SPACE_CHILD + + private fun Event.toSpaceChildInfo(spaceId: String, summary: SpaceChildSummaryResponse) = content.toModel<SpaceChildContent>()?.let { content -> + createSpaceChildInfo(spaceId, summary, content) + } + + private fun createSpaceChildInfo( + spaceId: String, + summary: SpaceChildSummaryResponse, + content: SpaceChildContent + ) = SpaceChildInfo( + childRoomId = summary.roomId, + isKnown = true, + roomType = summary.roomType, + name = summary.name, + topic = summary.topic, + avatarUrl = summary.avatarUrl, + order = content.order, + viaServers = content.via.orEmpty(), + activeMemberCount = summary.numJoinedMembers, + parentRoomId = spaceId, + suggested = content.suggested, + canonicalAlias = summary.canonicalAlias, + aliases = summary.aliases, + worldReadable = summary.isWorldReadable + ) + override suspend fun joinSpace(spaceIdOrAlias: String, reason: String?, viaServers: List<String>): JoinSpaceResult { @@ -192,10 +229,6 @@ internal class DefaultSpaceService @Inject constructor( leaveRoomTask.execute(LeaveRoomTask.Params(spaceId, reason)) } -// override fun getSpaceParentsOfRoom(roomId: String): List<SpaceSummary> { -// return spaceSummaryDataSource.getParentsOfRoom(roomId) -// } - override suspend fun setSpaceParent(childRoomId: String, parentSpaceId: String, canonical: Boolean, viaServers: List<String>) { // Should we perform some validation here?, // and if client want to bypass, it could use sendStateEvent directly? diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt index 2a396d6ee7b444f1435a6751d784b9686021a2e1..d59ca06c2cda6fdf852c47cedb54d23bc6c81c1d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt @@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.space import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.task.Task +import retrofit2.HttpException import javax.inject.Inject internal interface ResolveSpaceInfoTask : Task<ResolveSpaceInfoTask.Params, SpacesResponse> { @@ -28,7 +29,6 @@ internal interface ResolveSpaceInfoTask : Task<ResolveSpaceInfoTask.Params, Spac val maxDepth: Int?, val from: String?, val suggestedOnly: Boolean? -// val autoJoinOnly: Boolean? ) } @@ -36,14 +36,30 @@ internal class DefaultResolveSpaceInfoTask @Inject constructor( private val spaceApi: SpaceApi, private val globalErrorReceiver: GlobalErrorReceiver ) : ResolveSpaceInfoTask { - override suspend fun execute(params: ResolveSpaceInfoTask.Params): SpacesResponse { - return executeRequest(globalErrorReceiver) { + + override suspend fun execute(params: ResolveSpaceInfoTask.Params) = executeRequest(globalErrorReceiver) { + try { + getSpaceHierarchy(params) + } catch (e: HttpException) { + getUnstableSpaceHierarchy(params) + } + } + + private suspend fun getSpaceHierarchy(params: ResolveSpaceInfoTask.Params) = spaceApi.getSpaceHierarchy( spaceId = params.spaceId, suggestedOnly = params.suggestedOnly, limit = params.limit, maxDepth = params.maxDepth, - from = params.from) - } - } + from = params.from, + ) + + private suspend fun getUnstableSpaceHierarchy(params: ResolveSpaceInfoTask.Params) = + spaceApi.getSpaceHierarchyUnstable( + spaceId = params.spaceId, + suggestedOnly = params.suggestedOnly, + limit = params.limit, + maxDepth = params.maxDepth, + from = params.from, + ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt index edd10bc2ef9040c2e4e91cd10a7466d5735ad900..fda9b4b5bc6db6418bb8e54b5d7242cd3452d4bc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt @@ -31,11 +31,22 @@ internal interface SpaceApi { * @param from: Optional. Pagination token given to retrieve the next set of rooms. * Note that if a pagination token is provided, then the parameters given for suggested_only and max_depth must be the same. */ - @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc2946/rooms/{roomId}/hierarchy") + @GET(NetworkConstants.URI_API_PREFIX_PATH_V1 + "rooms/{roomId}/hierarchy") suspend fun getSpaceHierarchy( @Path("roomId") spaceId: String, @Query("suggested_only") suggestedOnly: Boolean?, @Query("limit") limit: Int?, @Query("max_depth") maxDepth: Int?, @Query("from") from: String?): SpacesResponse + + /** + * Unstable version of [getSpaceHierarchy] + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc2946/rooms/{roomId}/hierarchy") + suspend fun getSpaceHierarchyUnstable( + @Path("roomId") spaceId: String, + @Query("suggested_only") suggestedOnly: Boolean?, + @Query("limit") limit: Int?, + @Query("max_depth") maxDepth: Int?, + @Query("from") from: String?): SpacesResponse } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt index e0f273d0c2510c8b838e4d11d3bbbfc686bd4c72..b6a9c73d36596323af4f8855b7ac259e4fbad87c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt @@ -81,7 +81,7 @@ internal data class SpaceChildSummaryResponse( * Required. Whether the room may be viewed by guest users without joining. */ @Json(name = "world_readable") - val worldReadable: Boolean = false, + val isWorldReadable: Boolean = false, /** * Required. Whether guest users may join the room and participate in it. If they can, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt index f93da9705d0d58b91c2c893b150bb351f1b250b5..1bbf54a7888942fe298bc584fd1d53f9ecbc2761 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt @@ -110,6 +110,7 @@ internal class SyncResponseHandler @Inject constructor( // Start one big transaction monarchy.awaitTransaction { realm -> + // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local) measureTimeMillis { Timber.v("Handle rooms") reportSubtask(reporter, InitSyncStep.ImportingAccountRoom, 1, 0.7f) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt index b4da1a02cd918066ac3d32bcb73b18da45ad9bba..2136259f22a4f5bfb96ed91833b119fd4f1324b8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.sync import android.os.SystemClock import okhttp3.ResponseBody +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.initsync.InitSyncStep @@ -104,7 +105,11 @@ internal class DefaultSyncTask @Inject constructor( val isInitialSync = token == null if (isInitialSync) { // We might want to get the user information in parallel too - userStore.createOrUpdate(userId) + val user = tryOrNull { session.getProfileAsUser(userId) } + userStore.createOrUpdate( + userId = userId, + displayName = user?.displayName, + avatarUrl = user?.avatarUrl) defaultSyncStatusService.startRoot(InitSyncStep.ImportingAccount, 100) } // Maybe refresh the homeserver capabilities data we know diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt index f299d3effa1b7b458fc2e57f7adfc599ab26e587..9ae7b827777a1aa15515bb3664871f6363cfa20b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt @@ -38,7 +38,7 @@ private val loggerTag = LoggerTag("CryptoSyncHandler", LoggerTag.CRYPTO) internal class CryptoSyncHandler @Inject constructor(private val cryptoService: DefaultCryptoService, private val verificationService: DefaultVerificationService) { - fun handleToDevice(toDevice: ToDeviceSyncResponse, progressReporter: ProgressReporter? = null) { + suspend fun handleToDevice(toDevice: ToDeviceSyncResponse, progressReporter: ProgressReporter? = null) { val total = toDevice.events?.size ?: 0 toDevice.events?.forEachIndexed { index, event -> progressReporter?.reportProgress(index * 100F / total) @@ -66,7 +66,7 @@ internal class CryptoSyncHandler @Inject constructor(private val cryptoService: * @param timelineId the timeline identifier * @return true if the event has been decrypted */ - private fun decryptToDeviceEvent(event: Event, timelineId: String?): Boolean { + private suspend fun decryptToDeviceEvent(event: Event, timelineId: String?): Boolean { Timber.v("## CRYPTO | decryptToDeviceEvent") if (event.getClearType() == EventType.ENCRYPTED) { var result: MXEventDecryptionResult? = null @@ -80,6 +80,8 @@ internal class CryptoSyncHandler @Inject constructor(private val cryptoService: it.identityKey() == senderKey }?.deviceId ?: senderKey Timber.e("## CRYPTO | Failed to decrypt to device event from ${event.senderId}|$deviceId reason:<${event.mCryptoError ?: exception}>") + } catch (failure: Throwable) { + Timber.e(failure, "## CRYPTO | Failed to decrypt to device event from ${event.senderId}") } if (null != result) { @@ -91,7 +93,9 @@ internal class CryptoSyncHandler @Inject constructor(private val cryptoService: ) return true } else { - // should not happen + // Could happen for to device events + // None of the known session could decrypt the message + // In this case unwedging process might have been started (rate limited) Timber.e("## CRYPTO | ERROR NULL DECRYPTION RESULT from ${event.senderId}") } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt index fe173a35c3855f2993006f091cbd70174cc6281c..e5bed1218197e0d6f99416d52bb519a57dd5a43f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.sync.handler import io.realm.Realm +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.getPresenceContent import org.matrix.android.sdk.api.session.sync.model.PresenceSyncResponse @@ -27,27 +28,29 @@ import org.matrix.android.sdk.internal.database.query.updateDirectUserPresence import org.matrix.android.sdk.internal.database.query.updateUserPresence import javax.inject.Inject -internal class PresenceSyncHandler @Inject constructor() { +internal class PresenceSyncHandler @Inject constructor(private val matrixConfiguration: MatrixConfiguration) { fun handle(realm: Realm, presenceSyncResponse: PresenceSyncResponse?) { - presenceSyncResponse?.events - ?.filter { event -> event.type == EventType.PRESENCE } - ?.forEach { event -> - val content = event.getPresenceContent() ?: return@forEach - val userId = event.senderId ?: return@forEach - val userPresenceEntity = UserPresenceEntity( - userId = userId, - lastActiveAgo = content.lastActiveAgo, - statusMessage = content.statusMessage, - isCurrentlyActive = content.isCurrentlyActive, - avatarUrl = content.avatarUrl, - displayName = content.displayName - ).also { - it.presence = content.presence - } + if (matrixConfiguration.presenceSyncEnabled) { + presenceSyncResponse?.events + ?.filter { event -> event.type == EventType.PRESENCE } + ?.forEach { event -> + val content = event.getPresenceContent() ?: return@forEach + val userId = event.senderId ?: return@forEach + val userPresenceEntity = UserPresenceEntity( + userId = userId, + lastActiveAgo = content.lastActiveAgo, + statusMessage = content.statusMessage, + isCurrentlyActive = content.isCurrentlyActive, + avatarUrl = content.avatarUrl, + displayName = content.displayName + ).also { + it.presence = content.presence + } - storePresenceToDB(realm, userPresenceEntity) - } + storePresenceToDB(realm, userPresenceEntity) + } + } } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index 99e6521eb70b76656b477d0cedc15055b98d9d6d..8fe85f0d318aed6548320cb36b7cb8f240cda583 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -19,14 +19,17 @@ package org.matrix.android.sdk.internal.session.sync.handler.room import dagger.Lazy import io.realm.Realm import io.realm.kotlin.createObject +import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.Event 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.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.initsync.InitSyncStep 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.send.SendState +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType import org.matrix.android.sdk.api.session.sync.model.InvitedRoomSync import org.matrix.android.sdk.api.session.sync.model.LazyRoomSyncEphemeral import org.matrix.android.sdk.api.session.sync.model.RoomSync @@ -36,6 +39,7 @@ import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.helper.createOrUpdate import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.asDomain @@ -46,10 +50,13 @@ import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.deleteOnCascade +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.find import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.where @@ -84,6 +91,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, @UserId private val userId: String, + private val homeServerCapabilitiesService: HomeServerCapabilitiesService, private val lightweightSettingsStorage: LightweightSettingsStorage, private val timelineInput: TimelineInput, private val liveEventService: Lazy<StreamEventsManager>) { @@ -94,11 +102,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle data class LEFT(val data: Map<String, RoomSync>) : HandlingStrategy() } - fun handle(realm: Realm, - roomsSyncResponse: RoomsSyncResponse, - isInitialSync: Boolean, - aggregator: SyncResponsePostTreatmentAggregator, - reporter: ProgressReporter? = null) { + suspend fun handle(realm: Realm, + roomsSyncResponse: RoomsSyncResponse, + isInitialSync: Boolean, + aggregator: SyncResponsePostTreatmentAggregator, + reporter: ProgressReporter? = null) { Timber.v("Execute transaction from $this") handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter) handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), isInitialSync, aggregator, reporter) @@ -113,11 +121,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } // PRIVATE METHODS ***************************************************************************** - private fun handleRoomSync(realm: Realm, - handlingStrategy: HandlingStrategy, - isInitialSync: Boolean, - aggregator: SyncResponsePostTreatmentAggregator, - reporter: ProgressReporter?) { + private suspend fun handleRoomSync(realm: Realm, + handlingStrategy: HandlingStrategy, + isInitialSync: Boolean, + aggregator: SyncResponsePostTreatmentAggregator, + reporter: ProgressReporter?) { val insertType = if (isInitialSync) { EventInsertType.INITIAL_SYNC } else { @@ -150,11 +158,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle realm.insertOrUpdate(rooms) } - private fun insertJoinRoomsFromInitSync(realm: Realm, - handlingStrategy: HandlingStrategy.JOINED, - syncLocalTimeStampMillis: Long, - aggregator: SyncResponsePostTreatmentAggregator, - reporter: ProgressReporter?) { + private suspend fun insertJoinRoomsFromInitSync(realm: Realm, + handlingStrategy: HandlingStrategy.JOINED, + syncLocalTimeStampMillis: Long, + aggregator: SyncResponsePostTreatmentAggregator, + reporter: ProgressReporter?) { val bestChunkSize = computeBestChunkSize( listSize = handlingStrategy.data.keys.size, limit = (initialSyncStrategy as? InitialSyncStrategy.Optimized)?.maxRoomsToInsert ?: Int.MAX_VALUE @@ -192,12 +200,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } } - private fun handleJoinedRoom(realm: Realm, - roomId: String, - roomSync: RoomSync, - insertType: EventInsertType, - syncLocalTimestampMillis: Long, - aggregator: SyncResponsePostTreatmentAggregator): RoomEntity { + private suspend fun handleJoinedRoom(realm: Realm, + roomId: String, + roomSync: RoomSync, + insertType: EventInsertType, + syncLocalTimestampMillis: Long, + aggregator: SyncResponsePostTreatmentAggregator): RoomEntity { Timber.v("Handle join sync for room $roomId") val ephemeralResult = (roomSync.ephemeral as? LazyRoomSyncEphemeral.Parsed) @@ -343,15 +351,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle return roomEntity } - private fun handleTimelineEvents(realm: Realm, - roomId: String, - roomEntity: RoomEntity, - eventList: List<Event>, - prevToken: String? = null, - isLimited: Boolean = true, - insertType: EventInsertType, - syncLocalTimestampMillis: Long, - aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity { + private suspend fun handleTimelineEvents(realm: Realm, + roomId: String, + roomEntity: RoomEntity, + eventList: List<Event>, + prevToken: String? = null, + isLimited: Boolean = true, + insertType: EventInsertType, + syncLocalTimestampMillis: Long, + aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity { val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) if (isLimited && lastChunk != null) { lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true) @@ -379,7 +387,9 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle val isInitialSync = insertType == EventInsertType.INITIAL_SYNC if (event.isEncrypted() && !isInitialSync) { - decryptIfNeeded(event, roomId) + runBlocking { + decryptIfNeeded(event, roomId) + } } var contentToInject: String? = null if (!isInitialSync) { @@ -406,11 +416,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle rootStateEvent?.asDomain()?.getFixedRoomMemberContent() } - chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) + val timelineEventAdded = chunkEntity.addTimelineEvent( + roomId = roomId, + eventEntity = eventEntity, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = roomMemberContentsByUser) if (lightweightSettingsStorage.areThreadMessagesEnabled()) { eventEntity.rootThreadEventId?.let { // This is a thread event optimizedThreadSummaryMap[it] = eventEntity + // Add the same thread timeline event to Thread Chunk + addToThreadChunkIfNeeded(realm, roomId, it, timelineEventAdded, roomEntity) + if (homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreading) { + // Update thread summaries only if homeserver supports threading + ThreadSummaryEntity.createOrUpdate( + threadSummaryType = ThreadSummaryUpdateType.ADD, + realm = realm, + roomId = roomId, + threadEventEntity = eventEntity, + roomMemberContentsByUser = roomMemberContentsByUser, + userId = userId, + roomEntity = roomEntity) + } } ?: run { // This is a normal event or a root thread one optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity @@ -455,7 +482,29 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle return chunkEntity } - private fun decryptIfNeeded(event: Event, roomId: String) { + /** + * Adds new event to the appropriate thread chunk. If the event is already in + * the thread timeline and /relations api, we should not added it + */ + private fun addToThreadChunkIfNeeded(realm: Realm, + roomId: String, + threadId: String, + timelineEventEntity: TimelineEventEntity?, + roomEntity: RoomEntity) { + val eventId = timelineEventEntity?.eventId ?: return + + ChunkEntity.findLastForwardChunkOfThread(realm, roomId, threadId)?.let { threadChunk -> + val existingEvent = threadChunk.timelineEvents.find(eventId) + if (existingEvent?.ownedByThreadChunk == true) { + Timber.i("###THREADS RoomSyncHandler event:${timelineEventEntity.eventId} already exists, do not add") + return@addToThreadChunkIfNeeded + } + threadChunk.timelineEvents.add(0, timelineEventEntity) + roomEntity.addIfNecessary(threadChunk) + } + } + + private suspend fun decryptIfNeeded(event: Event, roomId: String) { try { // Event from sync does not have roomId, so add it to the event first val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt index f3a1523955383185046dcae80ec8d93d7f635440..db9799d51eb0b8648df0c79f187c78af11c32869 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.util.JsonDict @@ -161,7 +162,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( eventEntity: EventEntity? = null): String? { event ?: return null roomId ?: return null - if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null + if (lightweightSettingsStorage.areThreadMessagesEnabled() && !isReplyEvent(event)) return null handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event) if (!isThreadEvent(event)) return null val eventPayload = if (!event.isEncrypted()) { @@ -170,8 +171,9 @@ internal class ThreadsAwarenessHandler @Inject constructor( event.mxDecryptionResult?.payload?.toMutableMap() ?: return null } val eventBody = event.getDecryptedTextSummary() ?: return null + val threadRelation = getRootThreadRelationContent(event) val eventIdToInject = getPreviousEventOrRoot(event) ?: run { - return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload, threadRelation) } val eventToInject = getEventFromDB(realm, eventIdToInject) val eventToInjectBody = eventToInject?.getDecryptedTextSummary() @@ -183,17 +185,19 @@ internal class ThreadsAwarenessHandler @Inject constructor( roomId = roomId, eventBody = eventBody, eventToInject = eventToInject, - eventToInjectBody = eventToInjectBody) ?: return null + eventToInjectBody = eventToInjectBody, + threadRelation = threadRelation) ?: return null + // update the event contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent) } else { - contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload, threadRelation) } // Now lets try to find relations for improved results, while some events may come with reverse order eventEntity?.let { // When eventEntity is not null means that we are not from within roomSyncHandler - handleEventsThatRelatesTo(realm, roomId, event, eventBody, false) + handleEventsThatRelatesTo(realm, roomId, event, eventBody, false, threadRelation) } return contentForNonEncrypted } @@ -205,11 +209,16 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param event the current event received * @return The content to inject in the roomSyncHandler live events */ - private fun handleRootThreadEventsIfNeeded(realm: Realm, roomId: String, eventEntity: EventEntity?, event: Event): String? { + private fun handleRootThreadEventsIfNeeded( + realm: Realm, + roomId: String, + eventEntity: EventEntity?, + event: Event + ): String? { if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) { eventEntity?.let { val eventBody = event.getDecryptedTextSummary() ?: return null - return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true) + return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true, null) } } return null @@ -224,7 +233,14 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param isFromCache determines whether or not we already know this is root thread event * @return The content to inject in the roomSyncHandler live events */ - private fun handleEventsThatRelatesTo(realm: Realm, roomId: String, event: Event, eventBody: String, isFromCache: Boolean): String? { + private fun handleEventsThatRelatesTo( + realm: Realm, + roomId: String, + event: Event, + eventBody: String, + isFromCache: Boolean, + threadRelation: RelationDefaultContent? + ): String? { event.eventId ?: return null val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound -> @@ -236,7 +252,8 @@ internal class ThreadsAwarenessHandler @Inject constructor( roomId = roomId, eventBody = newEventBody, eventToInject = event, - eventToInjectBody = eventBody) ?: return null + eventToInjectBody = eventBody, + threadRelation = threadRelation) ?: return null return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent) } @@ -280,7 +297,9 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun injectEvent(roomId: String, eventBody: String, eventToInject: Event, - eventToInjectBody: String): Content? { + eventToInjectBody: String, + threadRelation: RelationDefaultContent? + ): Content? { val eventToInjectId = eventToInject.eventId ?: return null val eventIdToInjectSenderId = eventToInject.senderId.orEmpty() val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false) @@ -293,6 +312,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( eventBody) return MessageTextContent( + relatesTo = threadRelation, msgType = MessageType.MSGTYPE_TEXT, format = MessageFormat.FORMAT_MATRIX_HTML, body = eventBody, @@ -306,12 +326,14 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun injectFallbackIndicator(event: Event, eventBody: String, eventEntity: EventEntity?, - eventPayload: MutableMap<String, Any>): String? { + eventPayload: MutableMap<String, Any>, + threadRelation: RelationDefaultContent?): String? { val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format( "In reply to a thread", eventBody) val messageTextContent = MessageTextContent( + relatesTo = threadRelation, msgType = MessageType.MSGTYPE_TEXT, format = MessageFormat.FORMAT_MATRIX_HTML, body = eventBody, @@ -332,7 +354,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( .findAll() cacheEventRootId.add(rootThreadEventId) return threadList.filter { - it.asDomain().getRelationContentForType(RelationType.IO_THREAD)?.inReplyTo?.eventId == currentEventId + it.asDomain().getRelationContentForType(RelationType.THREAD)?.inReplyTo?.eventId == currentEventId } } @@ -350,7 +372,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param event */ private fun isThreadEvent(event: Event): Boolean = - event.content.toModel<MessageRelationContent>()?.relatesTo?.type == RelationType.IO_THREAD + event.content.toModel<MessageRelationContent>()?.relatesTo?.type == RelationType.THREAD /** * Returns the root thread eventId or null otherwise @@ -359,9 +381,22 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun getRootThreadEventId(event: Event): String? = event.content.toModel<MessageRelationContent>()?.relatesTo?.eventId + private fun getRootThreadRelationContent(event: Event): RelationDefaultContent? = + event.content.toModel<MessageRelationContent>()?.relatesTo + private fun getPreviousEventOrRoot(event: Event): String? = event.content.toModel<MessageRelationContent>()?.relatesTo?.inReplyTo?.eventId + /** + * Returns if we should html inject the current event. + */ + private fun isReplyEvent(event: Event): Boolean { + return isThreadEvent(event) && !isFallingBack(event) && getPreviousEventOrRoot(event) != null + } + + private fun isFallingBack(event: Event): Boolean = + event.content.toModel<MessageRelationContent>()?.relatesTo?.isFallingBack == true + @Suppress("UNCHECKED_CAST") private fun getValueFromPayload(payload: JsonDict?, key: String): String? { val content = payload?.get("content") as? JsonDict diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DirectChatsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DirectChatsHelper.kt index c7b125b5d6d1b245dc77c19bf9fa1f0433410576..c4fbdc75ab0d14018417dd6d9542948fbd38fcea 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DirectChatsHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DirectChatsHelper.kt @@ -24,8 +24,9 @@ import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.session.sync.model.accountdata.DirectMessagesContent import javax.inject.Inject -internal class DirectChatsHelper @Inject constructor(@SessionDatabase - private val realmConfiguration: RealmConfiguration) { +internal class DirectChatsHelper @Inject constructor( + @SessionDatabase private val realmConfiguration: RealmConfiguration +) { /** * @return a map of userId <-> list of roomId diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetPostAPIMediator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetPostAPIMediator.kt index 07f7c7cb868f08a56b4b8ace5fdc4f06eef0f316..1fa5e5f77120a2721666f913313750c977aeb59b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetPostAPIMediator.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetPostAPIMediator.kt @@ -88,10 +88,10 @@ internal class DefaultWidgetPostAPIMediator @Inject constructor(private val mosh } /* - * ********************************************************************************************* - * Message sending methods - * ********************************************************************************************* - */ + * ********************************************************************************************* + * Message sending methods + * ********************************************************************************************* + */ /** * Send a boolean response diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt index 96655b849dffb144c59d0e15768e6e16b4caa834..088e16095031cd085e492900a755ae4647fffffe 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt @@ -19,6 +19,7 @@ package org.matrix.android.sdk.api.auth.data import org.amshove.kluent.shouldBe import org.junit.Test import org.matrix.android.sdk.internal.auth.version.Versions +import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads import org.matrix.android.sdk.internal.auth.version.isSupportedBySdk class VersionsKtTest { @@ -53,5 +54,20 @@ class VersionsKtTest { Versions(supportedVersions = listOf("r0.5.0", "r0.6.0")).isSupportedBySdk() shouldBe true Versions(supportedVersions = listOf("r0.5.0", "r0.6.1")).isSupportedBySdk() shouldBe true Versions(supportedVersions = listOf("r0.6.0")).isSupportedBySdk() shouldBe true + Versions(supportedVersions = listOf("v1.6.0")).isSupportedBySdk() shouldBe true + } + + @Test + fun doesServerSupportThreads() { + Versions(supportedVersions = listOf("r0.6.0")).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("r0.9.1")).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("v1.2.0")).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("v1.3.0")).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("v1.3.1")).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("v1.5.1")).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("r0.6.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to true)).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("v1.2.1"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to true)).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("r0.6.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to false)).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("v1.4.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to false)).doesServerSupportThreads() shouldBe true } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt index c8be0f54871b23e29b0fdd0ce95c89157ea43d9d..31fd86fe65ecf172a167ac2febfb193973c511ae 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt @@ -16,7 +16,8 @@ package org.matrix.android.sdk.internal.session.pushers -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertFailsWith import org.amshove.kluent.shouldBeEqualTo import org.junit.Test @@ -39,6 +40,7 @@ private val A_JSON_PUSHER = JsonPusher( data = JsonPusherData(brand = "Element") ) +@ExperimentalCoroutinesApi class DefaultAddPusherTaskTest { private val pushersAPI = FakePushersAPI() @@ -55,7 +57,7 @@ class DefaultAddPusherTaskTest { fun `given no persisted pusher when adding Pusher then updates api and inserts result with Registered state`() { monarchy.givenWhereReturns<PusherEntity>(result = null) - runBlocking { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } + runTest { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } pushersAPI.verifySetPusher(A_JSON_PUSHER) monarchy.verifyInsertOrUpdate<PusherEntity> { @@ -70,7 +72,7 @@ class DefaultAddPusherTaskTest { val realmResult = PusherEntity(appDisplayName = null) monarchy.givenWhereReturns(result = realmResult) - runBlocking { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } + runTest { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } pushersAPI.verifySetPusher(A_JSON_PUSHER) @@ -85,7 +87,7 @@ class DefaultAddPusherTaskTest { pushersAPI.givenSetPusherErrors(SocketException()) assertFailsWith<SocketException> { - runBlocking { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } + runTest { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } } realmResult.state shouldBeEqualTo PusherState.FAILED_TO_REGISTER @@ -97,7 +99,7 @@ class DefaultAddPusherTaskTest { pushersAPI.givenSetPusherErrors(SocketException()) assertFailsWith<SocketException> { - runBlocking { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } + runTest { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } } } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/space/DefaultResolveSpaceInfoTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/space/DefaultResolveSpaceInfoTaskTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..7203f89629049dd2e97195163c9f03ced3565885 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/space/DefaultResolveSpaceInfoTaskTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2021 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.space + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver +import org.matrix.android.sdk.test.fakes.FakeSpaceApi +import org.matrix.android.sdk.test.fixtures.SpacesResponseFixture.aSpacesResponse +import retrofit2.HttpException +import retrofit2.Response + +@ExperimentalCoroutinesApi +internal class DefaultResolveSpaceInfoTaskTest { + + private val spaceApi = FakeSpaceApi() + private val globalErrorReceiver = FakeGlobalErrorReceiver() + private val resolveSpaceInfoTask = DefaultResolveSpaceInfoTask(spaceApi.instance, globalErrorReceiver) + + @Test + fun `given stable endpoint works, when execute, then return stable api data`() = runTest { + spaceApi.givenStableEndpointReturns(response) + + val result = resolveSpaceInfoTask.execute(spaceApi.params) + + result shouldBeEqualTo response + } + + @Test + fun `given stable endpoint fails, when execute, then fallback to unstable endpoint`() = runTest { + spaceApi.givenStableEndpointThrows(httpException) + spaceApi.givenUnstableEndpointReturns(response) + + val result = resolveSpaceInfoTask.execute(spaceApi.params) + + result shouldBeEqualTo response + } + + companion object { + private val response = aSpacesResponse() + private val httpException = HttpException(Response.error<SpacesResponse>(500, "".toResponseBody())) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/task/CoroutineSequencersTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/task/CoroutineSequencersTest.kt index 0abca8bee3dc47d7f4b29f9830699b16a8846738..149b964fd25a09f832732887a6f0334ccc2593fa 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/task/CoroutineSequencersTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/task/CoroutineSequencersTest.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test import org.matrix.android.sdk.MatrixTest @@ -51,7 +51,7 @@ class CoroutineSequencersTest : MatrixTest { .also { results.add(it) } } ) - runBlocking { + runTest { jobs.joinAll() } assertEquals(3, results.size) @@ -81,7 +81,7 @@ class CoroutineSequencersTest : MatrixTest { .also { results.add(it) } } ) - runBlocking { + runTest { jobs.joinAll() } assertEquals(3, results.size) @@ -109,7 +109,7 @@ class CoroutineSequencersTest : MatrixTest { ) // We are canceling the second job jobs[1].cancel() - runBlocking { + runTest { jobs.joinAll() } assertEquals(2, results.size) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSpaceApi.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSpaceApi.kt new file mode 100644 index 0000000000000000000000000000000000000000..d4fc9867913965da8cc6ec101f71d5df7dcd9491 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSpaceApi.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2021 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.test.fakes + +import io.mockk.coEvery +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.space.SpaceApi +import org.matrix.android.sdk.internal.session.space.SpacesResponse +import org.matrix.android.sdk.test.fixtures.ResolveSpaceInfoTaskParamsFixture + +internal class FakeSpaceApi { + + val instance: SpaceApi = mockk() + val params = ResolveSpaceInfoTaskParamsFixture.aResolveSpaceInfoTaskParams() + + fun givenStableEndpointReturns(response: SpacesResponse) { + coEvery { instance.getSpaceHierarchy(params.spaceId, params.suggestedOnly, params.limit, params.maxDepth, params.from) } returns response + } + + fun givenStableEndpointThrows(throwable: Throwable) { + coEvery { instance.getSpaceHierarchy(params.spaceId, params.suggestedOnly, params.limit, params.maxDepth, params.from) } throws throwable + } + + fun givenUnstableEndpointReturns(response: SpacesResponse) { + coEvery { instance.getSpaceHierarchyUnstable(params.spaceId, params.suggestedOnly, params.limit, params.maxDepth, params.from) } returns response + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/ResolveSpaceInfoTaskParamsFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/ResolveSpaceInfoTaskParamsFixture.kt new file mode 100644 index 0000000000000000000000000000000000000000..28f8c3637dc8157cae5b31aaf13947f3106062c2 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/ResolveSpaceInfoTaskParamsFixture.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2021 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.test.fixtures + +import org.matrix.android.sdk.internal.session.space.ResolveSpaceInfoTask + +internal object ResolveSpaceInfoTaskParamsFixture { + fun aResolveSpaceInfoTaskParams( + spaceId: String = "", + limit: Int? = null, + maxDepth: Int? = null, + from: String? = null, + suggestedOnly: Boolean? = null, + ) = ResolveSpaceInfoTask.Params( + spaceId, + limit, + maxDepth, + from, + suggestedOnly, + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SpacesResponseFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SpacesResponseFixture.kt new file mode 100644 index 0000000000000000000000000000000000000000..0a08331114455e454e461a0d337fe9bdb33bba0b --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SpacesResponseFixture.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2021 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.test.fixtures + +import org.matrix.android.sdk.internal.session.space.SpaceChildSummaryResponse +import org.matrix.android.sdk.internal.session.space.SpacesResponse + +internal object SpacesResponseFixture { + fun aSpacesResponse( + nextBatch: String? = null, + rooms: List<SpaceChildSummaryResponse>? = null, + ) = SpacesResponse( + nextBatch, + rooms, + ) +}