Merge pull request #7594 from vector-im/feature/bca/better_edit_validation

Better edit (replace handling)
This commit is contained in:
Valere 2022-11-24 17:22:41 +01:00 committed by GitHub
commit 035b1ebedc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1533 additions and 228 deletions

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

@ -0,0 +1 @@
Better validation of edits

View file

@ -0,0 +1,130 @@
/*
* 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
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.realm.Realm
import org.amshove.kluent.fail
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldNotBe
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.internal.database.mapper.EventMapper
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.SessionRealmModule
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.util.Normalizer
@RunWith(AndroidJUnit4::class)
class RealmSessionStoreMigration43Test {
@get:Rule val configurationFactory = TestRealmConfigurationFactory()
lateinit var context: Context
var realm: Realm? = null
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().context
}
@After
fun tearDown() {
realm?.close()
}
@Test
fun migrationShouldBeNeeed() {
val realmName = "session_42.realm"
val realmConfiguration = configurationFactory.createConfiguration(
realmName,
"efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0",
SessionRealmModule(),
43,
null
)
configurationFactory.copyRealmFromAssets(context, realmName, realmName)
try {
realm = Realm.getInstance(realmConfiguration)
fail("Should need a migration")
} catch (failure: Throwable) {
// nop
}
}
// Database key for alias `session_db_e00482619b2597069b1f192b86de7da9`: efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0
// $WEJ8U6Zsx3TDZx3qmHIOKh-mXe5kqL_MnPcIkStEwwI
// $11EtAQ8RYcudJVtw7e6B5Vm4ufCqKTOWKblY2U_wrpo
@Test
fun testMigration43() {
val realmName = "session_42.realm"
val migration = RealmSessionStoreMigration(Normalizer())
val realmConfiguration = configurationFactory.createConfiguration(
realmName,
"efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0",
SessionRealmModule(),
43,
migration
)
configurationFactory.copyRealmFromAssets(context, realmName, realmName)
realm = Realm.getInstance(realmConfiguration)
// assert that the edit from 42 are migrated
val editions = EventAnnotationsSummaryEntity
.where(realm!!, "\$WEJ8U6Zsx3TDZx3qmHIOKh-mXe5kqL_MnPcIkStEwwI")
.findFirst()
?.editSummary
?.editions
editions shouldNotBe null
editions!!.size shouldBe 1
val firstEdition = editions.first()
firstEdition?.eventId shouldBeEqualTo "\$DvOyA8vJxwGfTaJG3OEJVcL4isShyaVDnprihy38W28"
firstEdition?.isLocalEcho shouldBeEqualTo false
val editEvent = EventMapper.map(firstEdition!!.event!!)
val body = editEvent.content.toModel<MessageContent>()?.body
body shouldBeEqualTo "* Message 2 with edit"
// assert that the edit from 42 are migrated
val editionsOfE2E = EventAnnotationsSummaryEntity
.where(realm!!, "\$11EtAQ8RYcudJVtw7e6B5Vm4ufCqKTOWKblY2U_wrpo")
.findFirst()
?.editSummary
?.editions
editionsOfE2E shouldNotBe null
editionsOfE2E!!.size shouldBe 1
val firstEditionE2E = editionsOfE2E.first()
firstEditionE2E?.eventId shouldBeEqualTo "\$HUwJOQRCJwfPv7XSKvBPcvncjM0oR3q2tGIIIdv9Zts"
firstEditionE2E?.isLocalEcho shouldBeEqualTo false
val editEventE2E = EventMapper.map(firstEditionE2E!!.event!!)
val body2 = editEventE2E.getClearContent().toModel<MessageContent>()?.body
body2 shouldBeEqualTo "* Message 2, e2e edit"
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.internal.database
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.realm.Realm
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.matrix.android.sdk.internal.database.model.SessionRealmModule
import org.matrix.android.sdk.internal.util.Normalizer
@RunWith(AndroidJUnit4::class)
class SessionSanityMigrationTest {
@get:Rule val configurationFactory = TestRealmConfigurationFactory()
lateinit var context: Context
var realm: Realm? = null
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().context
}
@After
fun tearDown() {
realm?.close()
}
@Test
fun sessionDatabaseShouldMigrateGracefully() {
val realmName = "session_42.realm"
val migration = RealmSessionStoreMigration(Normalizer())
val realmConfiguration = configurationFactory.createConfiguration(
realmName,
"efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0",
SessionRealmModule(),
migration.schemaVersion,
migration
)
configurationFactory.copyRealmFromAssets(context, realmName, realmName)
realm = Realm.getInstance(realmConfiguration)
}
}

View file

@ -0,0 +1,196 @@
/*
* 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
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmMigration
import org.junit.rules.TemporaryFolder
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.lang.IllegalStateException
import java.util.Collections
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.Throws
/**
* Based on https://github.com/realm/realm-java/blob/master/realm/realm-library/src/testUtils/java/io/realm/TestRealmConfigurationFactory.java
*/
class TestRealmConfigurationFactory : TemporaryFolder() {
private val map: Map<RealmConfiguration, Boolean> = ConcurrentHashMap()
private val configurations = Collections.newSetFromMap(map)
@get:Synchronized private var isUnitTestFailed = false
private var testName = ""
private var tempFolder: File? = null
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
setTestName(description)
before()
try {
base.evaluate()
} catch (throwable: Throwable) {
setUnitTestFailed()
throw throwable
} finally {
after()
}
}
}
}
@Throws(Throwable::class)
override fun before() {
Realm.init(InstrumentationRegistry.getInstrumentation().targetContext)
super.before()
}
override fun after() {
try {
for (configuration in configurations) {
Realm.deleteRealm(configuration)
}
} catch (e: IllegalStateException) {
// Only throws the exception caused by deleting the opened Realm if the test case itself doesn't throw.
if (!isUnitTestFailed) {
throw e
}
} finally {
// This will delete the temp directory.
super.after()
}
}
@Throws(IOException::class)
override fun create() {
super.create()
tempFolder = File(super.getRoot(), testName)
check(!(tempFolder!!.exists() && !tempFolder!!.delete())) { "Could not delete folder: " + tempFolder!!.absolutePath }
check(tempFolder!!.mkdir()) { "Could not create folder: " + tempFolder!!.absolutePath }
}
override fun getRoot(): File {
checkNotNull(tempFolder) { "the temporary folder has not yet been created" }
return tempFolder!!
}
/**
* To be called in the [.apply].
*/
protected fun setTestName(description: Description) {
testName = description.displayName
}
@Synchronized
fun setUnitTestFailed() {
isUnitTestFailed = true
}
// This builder creates a configuration that is *NOT* managed.
// You have to delete it yourself.
private fun createConfigurationBuilder(): RealmConfiguration.Builder {
return RealmConfiguration.Builder().directory(root)
}
fun String.decodeHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
fun createConfiguration(
name: String,
key: String?,
module: Any,
schemaVersion: Long,
migration: RealmMigration?
): RealmConfiguration {
val builder = createConfigurationBuilder()
builder
.directory(root)
.name(name)
.apply {
if (key != null) {
encryptionKey(key.decodeHex())
}
}
.modules(module)
// Allow writes on UI
.allowWritesOnUiThread(true)
.schemaVersion(schemaVersion)
.apply {
migration?.let { migration(it) }
}
val configuration = builder.build()
configurations.add(configuration)
return configuration
}
// Copies a Realm file from assets to temp dir
@Throws(IOException::class)
fun copyRealmFromAssets(context: Context, realmPath: String, newName: String) {
val config = RealmConfiguration.Builder()
.directory(root)
.name(newName)
.build()
copyRealmFromAssets(context, realmPath, config)
}
@Throws(IOException::class)
fun copyRealmFromAssets(context: Context, realmPath: String, config: RealmConfiguration) {
check(!File(config.path).exists()) { String.format(Locale.ENGLISH, "%s exists!", config.path) }
val outFile = File(config.realmDirectory, config.realmFileName)
copyFileFromAssets(context, realmPath, outFile)
}
@Throws(IOException::class)
fun copyFileFromAssets(context: Context, assetPath: String?, outFile: File?) {
var stream: InputStream? = null
var os: FileOutputStream? = null
try {
stream = context.assets.open(assetPath!!)
os = FileOutputStream(outFile)
val buf = ByteArray(1024)
var bytesRead: Int
while (stream.read(buf).also { bytesRead = it } > -1) {
os.write(buf, 0, bytesRead)
}
} finally {
if (stream != null) {
try {
stream.close()
} catch (ignore: IOException) {
}
}
if (os != null) {
try {
os.close()
} catch (ignore: IOException) {
}
}
}
}
}

View file

@ -38,5 +38,4 @@ data class AggregatedAnnotation(
override val limited: Boolean? = false,
override val count: Int? = 0,
val chunk: List<RelationChunkInfo>? = null
) : UnsignedRelationInfo

View file

@ -19,7 +19,8 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* <code>
* Server side relation aggregation.
* ```
* {
* "m.annotation": {
* "chunk": [
@ -43,12 +44,13 @@ import com.squareup.moshi.JsonClass
* "count": 1
* }
* }
* </code>
* ```
*/
@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.replace") val replaces: AggregatedReplace? = null,
@Json(name = RelationType.THREAD) val latestThread: LatestThreadUnsignedRelation? = null
)

View file

@ -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.events.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Note that there can be multiple events with an m.replace relationship to a given event (for example, if an event is edited multiple times).
* These should be aggregated by the homeserver.
* https://spec.matrix.org/v1.4/client-server-api/#server-side-aggregation-of-mreplace-relationships
*
*/
@JsonClass(generateAdapter = true)
data class AggregatedReplace(
@Json(name = "event_id") val eventId: String? = null,
@Json(name = "origin_server_ts") val originServerTs: Long? = null,
@Json(name = "sender") val senderId: String? = null,
)

View file

@ -0,0 +1,46 @@
/*
* 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
fun Event.toValidDecryptedEvent(): ValidDecryptedEvent? {
if (!this.isEncrypted()) return null
val decryptedContent = this.getDecryptedContent() ?: return null
val eventId = this.eventId ?: return null
val roomId = this.roomId ?: return null
val type = this.getDecryptedType() ?: return null
val senderKey = this.getSenderKey() ?: return null
val algorithm = this.content?.get("algorithm") as? String ?: return null
// copy the relation as it's in clear in the encrypted content
val updatedContent = this.content.get("m.relates_to")?.let {
decryptedContent.toMutableMap().apply {
put("m.relates_to", it)
}
} ?: decryptedContent
return ValidDecryptedEvent(
type = type,
eventId = eventId,
clearContent = updatedContent,
prevContent = this.prevContent,
originServerTs = this.originServerTs ?: 0,
cryptoSenderKey = senderKey,
roomId = roomId,
unsignedData = this.unsignedData,
redacts = this.redacts,
algorithm = algorithm
)
}

View file

@ -0,0 +1,37 @@
/*
* 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 org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
data class ValidDecryptedEvent(
val type: String,
val eventId: String,
val clearContent: Content,
val prevContent: Content? = null,
val originServerTs: Long,
val cryptoSenderKey: String,
val roomId: String,
val unsignedData: UnsignedData? = null,
val redacts: String? = null,
val algorithm: String,
)
fun ValidDecryptedEvent.getRelationContent(): RelationDefaultContent? {
return clearContent.toModel<MessageRelationContent?>()?.relatesTo
}

View file

@ -15,10 +15,10 @@
*/
package org.matrix.android.sdk.api.session.room.model
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.Event
data class EditAggregatedSummary(
val latestContent: Content? = null,
val latestEdit: Event? = null,
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
val sourceEvents: List<String>,
val localEchos: List<String>,

View file

@ -18,6 +18,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.Content
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
@ -142,13 +143,21 @@ fun TimelineEvent.getEditedEventId(): String? {
fun TimelineEvent.getLastMessageContent(): MessageContent? {
return when (root.getClearType()) {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageBeaconInfoContent>()
in EventType.BEACON_LOCATION_DATA -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
// XXX
// Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing
// so toModel<MessageContent> won't parse them correctly
// It's discriminated on event type instead. Maybe it shouldn't be MessageContent at all to avoid confusion?
in EventType.POLL_START -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessagePollContent>()
in EventType.STATE_ROOM_BEACON_INFO -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconInfoContent>()
in EventType.BEACON_LOCATION_DATA -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>()
else -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
}
}
fun TimelineEvent.getLastEditNewContent(): Content? {
return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessageContent>()?.newContent
}
/**
* Returns true if it's a reply.
*/

View file

@ -26,17 +26,17 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.system.measureTimeMillis
internal fun <T> CoroutineScope.asyncTransaction(monarchy: Monarchy, transaction: suspend (realm: Realm) -> T) {
internal fun <T> CoroutineScope.asyncTransaction(monarchy: Monarchy, transaction: (realm: Realm) -> T) {
asyncTransaction(monarchy.realmConfiguration, transaction)
}
internal fun <T> CoroutineScope.asyncTransaction(realmConfiguration: RealmConfiguration, transaction: suspend (realm: Realm) -> T) {
internal fun <T> CoroutineScope.asyncTransaction(realmConfiguration: RealmConfiguration, transaction: (realm: Realm) -> T) {
launch {
awaitTransaction(realmConfiguration, transaction)
}
}
internal suspend fun <T> awaitTransaction(config: RealmConfiguration, transaction: suspend (realm: Realm) -> T): T {
internal suspend fun <T> awaitTransaction(config: RealmConfiguration, transaction: (realm: Realm) -> T): T {
return withContext(Realm.WRITE_EXECUTOR.asCoroutineDispatcher()) {
Realm.getInstance(config).use { bgRealm ->
bgRealm.beginTransaction()

View file

@ -59,6 +59,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo039
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo040
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo041
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo042
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo043
import org.matrix.android.sdk.internal.util.Normalizer
import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
import javax.inject.Inject
@ -67,7 +68,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
private val normalizer: Normalizer
) : MatrixRealmMigration(
dbName = "Session",
schemaVersion = 42L,
schemaVersion = 43L,
) {
/**
* Forces all RealmSessionStoreMigration instances to be equal.
@ -119,5 +120,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 40) MigrateSessionTo040(realm).perform()
if (oldVersion < 41) MigrateSessionTo041(realm).perform()
if (oldVersion < 42) MigrateSessionTo042(realm).perform()
if (oldVersion < 43) MigrateSessionTo043(realm).perform()
}
}

View file

@ -83,7 +83,6 @@ internal fun ChunkEntity.addTimelineEvent(
this.eventId = eventId
this.roomId = roomId
this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst()
?.also { it.cleanUp(eventEntity.sender) }
this.readReceipts = readReceiptsSummaryEntity
this.displayIndex = displayIndex
this.ownedByThreadChunk = ownedByThreadChunk

View file

@ -19,7 +19,6 @@ 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 kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.session.crypto.CryptoService
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
@ -103,32 +102,6 @@ internal fun ThreadSummaryEntity.updateThreadSummaryLatestEvent(
}
}
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 fun ThreadSummaryEntity.Companion.createOrUpdate(
threadSummaryType: ThreadSummaryUpdateType,
realm: Realm,

View file

@ -0,0 +1,45 @@
/*
* 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.internal.database.mapper
import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary
import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.EditionOfEvent
internal object EditAggregatedSummaryEntityMapper {
fun map(summary: EditAggregatedSummaryEntity?): EditAggregatedSummary? {
summary ?: return null
/**
* The most recent event is determined by comparing origin_server_ts;
* if two or more replacement events have identical origin_server_ts,
* the event with the lexicographically largest event_id is treated as more recent.
*/
val latestEdition = summary.editions.sortedWith(compareBy<EditionOfEvent> { it.timestamp }.thenBy { it.eventId })
.lastOrNull() ?: return null
val editEvent = latestEdition.event
return EditAggregatedSummary(
latestEdit = editEvent?.asDomain(),
sourceEvents = summary.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho }
.map { editionOfEvent -> editionOfEvent.eventId },
localEchos = summary.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho }
.map { editionOfEvent -> editionOfEvent.eventId },
lastEditTs = latestEdition.timestamp
)
}
}

View file

@ -16,7 +16,6 @@
package org.matrix.android.sdk.internal.database.mapper
import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReactionAggregatedSummary
import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedSummary
@ -35,18 +34,7 @@ internal object EventAnnotationsSummaryMapper {
it.sourceLocalEcho.toList()
)
},
editSummary = annotationsSummary.editSummary
?.let {
val latestEdition = it.editions.maxByOrNull { editionOfEvent -> editionOfEvent.timestamp } ?: return@let null
EditAggregatedSummary(
latestContent = ContentMapper.map(latestEdition.content),
sourceEvents = it.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho }
.map { editionOfEvent -> editionOfEvent.eventId },
localEchos = it.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho }
.map { editionOfEvent -> editionOfEvent.eventId },
lastEditTs = latestEdition.timestamp
)
},
editSummary = EditAggregatedSummaryEntityMapper.map(annotationsSummary.editSummary),
referencesAggregatedSummary = annotationsSummary.referencesSummaryEntity?.let {
ReferencesAggregatedSummary(
ContentMapper.map(it.content),

View file

@ -25,11 +25,11 @@ internal class MigrateSessionTo008(realm: DynamicRealm) : RealmMigrator(realm, 8
override fun doMigrate(realm: DynamicRealm) {
val editionOfEventSchema = realm.schema.create("EditionOfEvent")
.addField(EditionOfEventFields.CONTENT, String::class.java)
.addField("content", String::class.java)
.addField(EditionOfEventFields.EVENT_ID, String::class.java)
.setRequired(EditionOfEventFields.EVENT_ID, true)
.addField(EditionOfEventFields.SENDER_ID, String::class.java)
.setRequired(EditionOfEventFields.SENDER_ID, true)
.addField("senderId", String::class.java)
.setRequired("senderId", true)
.addField(EditionOfEventFields.TIMESTAMP, Long::class.java)
.addField(EditionOfEventFields.IS_LOCAL_ECHO, Boolean::class.java)

View file

@ -0,0 +1,43 @@
/*
* 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 org.matrix.android.sdk.internal.database.model.EditionOfEventFields
import org.matrix.android.sdk.internal.database.model.EventEntityFields
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.util.database.RealmMigrator
internal class MigrateSessionTo043(realm: DynamicRealm) : RealmMigrator(realm, 43) {
override fun doMigrate(realm: DynamicRealm) {
// content(string) & senderId(string) have been removed and replaced by a link to the actual event
realm.schema.get("EditionOfEvent")
?.addRealmObjectField(EditionOfEventFields.EVENT.`$`, realm.schema.get("EventEntity")!!)
?.transform { dynamicObject ->
realm.where("EventEntity")
.equalTo(EventEntityFields.EVENT_ID, dynamicObject.getString(EditionOfEventFields.EVENT_ID))
.equalTo(EventEntityFields.SENDER, dynamicObject.getString("senderId"))
.findFirst()
.let {
dynamicObject.setObject(EditionOfEventFields.EVENT.`$`, it)
}
}
?.removeField("senderId")
?.removeField("content")
}
}

View file

@ -32,9 +32,8 @@ internal open class EditAggregatedSummaryEntity(
@RealmClass(embedded = true)
internal open class EditionOfEvent(
var senderId: String = "",
var eventId: String = "",
var content: String? = null,
var timestamp: Long = 0,
var isLocalEcho: Boolean = false
var isLocalEcho: Boolean = false,
var event: EventEntity? = null,
) : RealmObject()

View file

@ -19,7 +19,6 @@ import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import timber.log.Timber
internal open class EventAnnotationsSummaryEntity(
@PrimaryKey
@ -32,21 +31,6 @@ internal open class EventAnnotationsSummaryEntity(
var liveLocationShareAggregatedSummary: LiveLocationShareAggregatedSummaryEntity? = null,
) : RealmObject() {
/**
* Cleanup undesired editions, done by users different from the originalEventSender.
*/
fun cleanUp(originalEventSenderId: String?) {
originalEventSenderId ?: return
editSummary?.editions?.filter {
it.senderId != originalEventSenderId
}
?.forEach {
Timber.w("Deleting an edition from ${it.senderId} of event sent by $originalEventSenderId")
it.deleteFromRealm()
}
}
companion object
}

View file

@ -24,7 +24,7 @@ internal interface EventInsertLiveProcessor {
fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean
suspend fun process(realm: Realm, event: Event)
fun process(realm: Realm, event: Event)
/**
* Called after transaction.

View file

@ -54,7 +54,7 @@ internal class CallEventProcessor @Inject constructor(private val callSignalingH
return allowedTypes.contains(eventType)
}
override suspend fun process(realm: Realm, event: Event) {
override fun process(realm: Realm, event: Event) {
eventsToPostProcess.add(event)
}

View file

@ -0,0 +1,126 @@
/*
* 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
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
import timber.log.Timber
import javax.inject.Inject
internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCryptoStore) {
sealed class EditValidity {
object Valid : EditValidity()
data class Invalid(val reason: String) : EditValidity()
object Unknown : EditValidity()
}
/**
* There are a number of requirements on replacement events, which must be satisfied for the replacement
* to be considered valid:
* As with all event relationships, the original event and replacement event must have the same room_id
* (i.e. you cannot send an event in one room and then an edited version in a different room).
* The original event and replacement event must have the same sender (i.e. you cannot edit someone elses messages).
* The replacement and original events must have the same type (i.e. you cannot change the original events type).
* The replacement and original events must not have a state_key property (i.e. you cannot edit state events at all).
* The original event must not, itself, have a rel_type of m.replace
* (i.e. you cannot edit an edit though you can send multiple edits for a single original event).
* The replacement event (once decrypted, if appropriate) must have an m.new_content property.
*
* If the original event was encrypted, the replacement should be too.
*/
fun validateEdit(originalEvent: Event?, replaceEvent: Event): EditValidity {
Timber.v("###REPLACE valide event $originalEvent replaced $replaceEvent")
// we might not know the original event at that time. In this case we can't perform the validation
// Edits should be revalidated when the original event is received
if (originalEvent == null) {
return EditValidity.Unknown
}
if (LocalEcho.isLocalEchoId(replaceEvent.eventId.orEmpty())) {
// Don't validate local echo
return EditValidity.Unknown
}
if (originalEvent.roomId != replaceEvent.roomId) {
return EditValidity.Invalid("original event and replacement event must have the same room_id")
}
if (originalEvent.isStateEvent() || replaceEvent.isStateEvent()) {
return EditValidity.Invalid("replacement and original events must not have a state_key property")
}
// check it's from same sender
if (originalEvent.isEncrypted()) {
if (!replaceEvent.isEncrypted()) return EditValidity.Invalid("If the original event was encrypted, the replacement should be too")
val originalDecrypted = originalEvent.toValidDecryptedEvent()
?: return EditValidity.Unknown // UTD can't decide
val replaceDecrypted = replaceEvent.toValidDecryptedEvent()
?: return EditValidity.Unknown // UTD can't decide
val originalCryptoSenderId = cryptoStore.deviceWithIdentityKey(originalDecrypted.cryptoSenderKey)?.userId
val editCryptoSenderId = cryptoStore.deviceWithIdentityKey(replaceDecrypted.cryptoSenderKey)?.userId
if (originalDecrypted.getRelationContent()?.type == RelationType.REPLACE) {
return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ")
}
if (originalCryptoSenderId == null || editCryptoSenderId == null) {
// mm what can we do? we don't know if it's cryptographically from same user?
// let valid and UI should display send by deleted device warning?
val bestEffortOriginal = originalCryptoSenderId ?: originalEvent.senderId
val bestEffortEdit = editCryptoSenderId ?: replaceEvent.senderId
if (bestEffortOriginal != bestEffortEdit) {
return EditValidity.Invalid("original event and replacement event must have the same sender")
}
} else {
if (originalCryptoSenderId != editCryptoSenderId) {
return EditValidity.Invalid("Crypto: original event and replacement event must have the same sender")
}
}
if (originalDecrypted.type != replaceDecrypted.type) {
return EditValidity.Invalid("replacement and original events must have the same type")
}
if (replaceDecrypted.clearContent.toModel<MessageContent>()?.newContent == null) {
return EditValidity.Invalid("replacement event must have an m.new_content property")
}
} else {
if (originalEvent.getRelationContent()?.type == RelationType.REPLACE) {
return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ")
}
// check the sender
if (originalEvent.senderId != replaceEvent.senderId) {
return EditValidity.Invalid("original event and replacement event must have the same sender")
}
if (originalEvent.type != replaceEvent.type) {
return EditValidity.Invalid("replacement and original events must have the same type")
}
if (replaceEvent.content.toModel<MessageContent>()?.newContent == null) {
return EditValidity.Invalid("replacement event must have an m.new_content property")
}
}
return EditValidity.Valid
}
}

View file

@ -42,6 +42,7 @@ import org.matrix.android.sdk.internal.crypto.verification.toState
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.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.EditionOfEvent
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
@ -72,6 +73,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
private val sessionManager: SessionManager,
private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor,
private val pollAggregationProcessor: PollAggregationProcessor,
private val editValidator: EventEditValidator,
private val clock: Clock,
) : EventInsertLiveProcessor {
@ -79,13 +81,14 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
EventType.MESSAGE,
EventType.REDACTION,
EventType.REACTION,
// The aggregator handles verification events but just to render tiles in the timeline
// It's not participating in verification itself, just timeline display
EventType.KEY_VERIFICATION_DONE,
EventType.KEY_VERIFICATION_CANCEL,
EventType.KEY_VERIFICATION_ACCEPT,
EventType.KEY_VERIFICATION_START,
EventType.KEY_VERIFICATION_MAC,
// TODO Add ?
// EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_KEY,
EventType.ENCRYPTED
) + EventType.POLL_START + EventType.POLL_RESPONSE + EventType.POLL_END + EventType.STATE_ROOM_BEACON_INFO + EventType.BEACON_LOCATION_DATA
@ -94,7 +97,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
return allowedTypes.contains(eventType)
}
override suspend fun process(realm: Realm, event: Event) {
override fun process(realm: Realm, event: Event) {
try { // Temporary catch, should be removed
val roomId = event.roomId
if (roomId == null) {
@ -102,7 +105,31 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
return
}
val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "")
when (event.type) {
// It might be a late decryption of the original event or a event received when back paginating?
// let's check if there is already a summary for it and do some cleaning
if (!isLocalEcho) {
EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId.orEmpty())
.findFirst()
?.editSummary
?.editions
?.forEach { editionOfEvent ->
EventEntity.where(realm, editionOfEvent.eventId).findFirst()?.asDomain()?.let { editEvent ->
when (editValidator.validateEdit(event, editEvent)) {
is EventEditValidator.EditValidity.Invalid -> {
// delete it, it was invalid
Timber.v("## Replace: Removing a previously accepted edit for event ${event.eventId}")
editionOfEvent.deleteFromRealm()
}
else -> {
// nop
}
}
}
}
}
when (event.getClearType()) {
EventType.REACTION -> {
// we got a reaction!!
Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}")
@ -113,21 +140,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
Timber.v("###REACTION 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 }
}
// XXX do something for aggregated edits?
// it's a bit strange as it would require to do a server query to get the edition?
}
val content: MessageContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
val relationContent = event.getRelationContent()
if (relationContent?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, content, roomId, isLocalEcho)
handleReplace(realm, event, roomId, isLocalEcho, relationContent.eventId)
}
}
EventType.KEY_VERIFICATION_DONE,
EventType.KEY_VERIFICATION_CANCEL,
EventType.KEY_VERIFICATION_ACCEPT,
@ -142,74 +165,31 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
}
}
// As for now Live event processors are not receiving UTD events.
// They will get an update if the event is decrypted later
EventType.ENCRYPTED -> {
// Relation type is in clear
// Relation type is in clear, it might be possible to do some things?
// Notice that if the event is decrypted later, process be called again
val encryptedEventContent = event.content.toModel<EncryptedEventContent>()
if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE ||
encryptedEventContent?.relatesTo?.type == RelationType.RESPONSE
) {
event.getClearContent().toModel<MessageContent>()?.let {
if (encryptedEventContent.relatesTo.type == RelationType.REPLACE) {
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() in EventType.POLL_RESPONSE) {
sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
pollAggregationProcessor.handlePollResponseEvent(session, realm, event)
}
}
when (encryptedEventContent?.relatesTo?.type) {
RelationType.REPLACE -> {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
}
} else if (encryptedEventContent?.relatesTo?.type == RelationType.REFERENCE) {
when (event.getClearType()) {
EventType.KEY_VERIFICATION_DONE,
EventType.KEY_VERIFICATION_CANCEL,
EventType.KEY_VERIFICATION_ACCEPT,
EventType.KEY_VERIFICATION_START,
EventType.KEY_VERIFICATION_MAC,
EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_KEY -> {
Timber.v("## SAS REF in room $roomId for event ${event.eventId}")
encryptedEventContent.relatesTo.eventId?.let {
handleVerification(realm, event, roomId, isLocalEcho, it)
}
}
in EventType.POLL_RESPONSE -> {
event.getClearContent().toModel<MessagePollResponseContent>(catchError = true)?.let {
sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
pollAggregationProcessor.handlePollResponseEvent(session, realm, event)
}
}
}
in EventType.POLL_END -> {
sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
getPowerLevelsHelper(event.roomId)?.let {
pollAggregationProcessor.handlePollEndEvent(session, it, realm, event)
}
}
}
in EventType.BEACON_LOCATION_DATA -> {
handleBeaconLocationData(event, realm, roomId, isLocalEcho)
}
RelationType.RESPONSE -> {
// can we / should we do we something for UTD response??
Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
} else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) {
// Reaction
if (event.getClearType() == EventType.REACTION) {
// we got a reaction!!
Timber.v("###REACTION e2e in room $roomId , reaction eventID ${event.eventId}")
handleReaction(realm, event, roomId, isLocalEcho)
RelationType.REFERENCE -> {
// can we / should we do we something for UTD reference??
Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
RelationType.ANNOTATION -> {
// can we / should we do we something for UTD annotation??
Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
}
// 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,9 +197,6 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
when (eventToPrune.type) {
EventType.MESSAGE -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
// val unsignedData = EventMapper.map(eventToPrune).unsignedData
// ?: UnsignedData(null, null)
// was this event a m.replace
val contentModel = ContentMapper.map(eventToPrune.content)?.toModel<MessageContent>()
if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
@ -236,7 +213,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, content, roomId, isLocalEcho)
handleReplace(realm, event, roomId, isLocalEcho, content.relatesTo.eventId)
}
}
in EventType.POLL_RESPONSE -> {
@ -274,23 +251,22 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
private fun handleReplace(
realm: Realm,
event: Event,
content: MessageContent,
roomId: String,
isLocalEcho: Boolean,
relatedEventId: String? = null
relatedEventId: String?
) {
val eventId = event.eventId ?: return
val targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return
val newContent = content.newContent ?: return
// Check that the sender is the same
val targetEventId = relatedEventId ?: return
val editedEvent = EventEntity.where(realm, targetEventId).findFirst()
if (editedEvent == null) {
// We do not know yet about the edited event
} else if (editedEvent.sender != event.senderId) {
// Edited by someone else, ignore
Timber.w("Ignore edition by someone else")
return
when (val validity = editValidator.validateEdit(editedEvent?.asDomain(), event)) {
is EventEditValidator.EditValidity.Invalid -> return Unit.also {
Timber.w("Dropping invalid edit ${event.eventId}, reason:${validity.reason}")
}
EventEditValidator.EditValidity.Unknown, // we can't drop the source event might be unknown, will be validated later
EventEditValidator.EditValidity.Valid -> {
// continue
}
}
// ok, this is a replace
@ -305,11 +281,10 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
.also { editSummary ->
editSummary.editions.add(
EditionOfEvent(
senderId = event.senderId ?: "",
eventId = event.eventId,
content = ContentMapper.map(newContent),
timestamp = if (isLocalEcho) 0 else event.originServerTs ?: 0,
isLocalEcho = isLocalEcho
event = EventEntity.where(realm, eventId).findFirst(),
timestamp = if (isLocalEcho) clock.epochMillis() else event.originServerTs ?: clock.epochMillis(),
isLocalEcho = isLocalEcho,
)
)
}
@ -326,17 +301,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
// ok it has already been managed
Timber.v("###REPLACE Receiving remote echo of edit (edit already done)")
existingSummary.editions.firstOrNull { it.eventId == txId }?.let {
it.eventId = event.eventId
it.eventId = eventId
it.timestamp = event.originServerTs ?: clock.epochMillis()
it.isLocalEcho = false
it.event = EventEntity.where(realm, eventId).findFirst()
}
} else {
Timber.v("###REPLACE Computing aggregated edit summary (isLocalEcho:$isLocalEcho)")
existingSummary.editions.add(
EditionOfEvent(
senderId = event.senderId ?: "",
eventId = event.eventId,
content = ContentMapper.map(newContent),
eventId = eventId,
event = EventEntity.where(realm, eventId).findFirst(),
timestamp = if (isLocalEcho) {
clock.epochMillis()
} else {
@ -501,7 +476,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
val sourceToDiscard = eventSummary.editSummary?.editions?.firstOrNull { it.eventId == redacted.eventId }
if (sourceToDiscard == null) {
Timber.w("Redaction of a replace that was not known in aggregation $sourceToDiscard")
Timber.w("Redaction of a replace that was not known in aggregation")
return
}
// Need to remove this event from the edition list
@ -599,12 +574,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
private fun handleBeaconLocationData(event: Event, realm: Realm, roomId: String, isLocalEcho: Boolean) {
event.getClearContent().toModel<MessageBeaconLocationDataContent>(catchError = true)?.let {
liveLocationAggregationProcessor.handleBeaconLocationData(
realm,
event,
it,
roomId,
event.getRelationContent()?.eventId,
isLocalEcho
realm = realm,
event = event,
content = it,
roomId = roomId,
relatedEventId = event.getRelationContent()?.eventId,
isLocalEcho = isLocalEcho
)
}
}

View file

@ -21,6 +21,7 @@ import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.kotlin.createObject
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
import org.matrix.android.sdk.api.session.room.model.Membership
@ -95,7 +96,7 @@ internal class DefaultCreateLocalRoomTask @Inject constructor(
* Create a local room entity from the given room creation params.
* This will also generate and store in database the chunk and the events related to the room params in order to retrieve and display the local room.
*/
private suspend fun createLocalRoomEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) {
private fun createLocalRoomEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) {
RoomEntity.getOrCreate(realm, roomId).apply {
membership = Membership.JOIN
chunks.add(createLocalRoomChunk(realm, roomId, createRoomBody))
@ -148,13 +149,16 @@ internal class DefaultCreateLocalRoomTask @Inject constructor(
*
* @return a chunk entity
*/
private suspend fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity {
private fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity {
val chunkEntity = realm.createObject<ChunkEntity>().apply {
isLastBackward = true
isLastForward = true
}
val eventList = createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody))
// Can't suspend when using realm as it could jump thread
val eventList = runBlocking {
createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody))
}
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
for (event in eventList) {

View file

@ -30,7 +30,7 @@ import javax.inject.Inject
internal class RoomCreateEventProcessor @Inject constructor() : EventInsertLiveProcessor {
override suspend fun process(realm: Realm, event: Event) {
override fun process(realm: Realm, event: Event) {
val createRoomContent = event.getClearContent().toModel<RoomCreateContent>()
val predecessorRoomId = createRoomContent?.predecessor?.roomId ?: return

View file

@ -40,7 +40,7 @@ internal class LiveLocationShareRedactionEventProcessor @Inject constructor() :
return eventType == EventType.REDACTION && insertType != EventInsertType.LOCAL_ECHO
}
override suspend fun process(realm: Realm, event: Event) {
override fun process(realm: Realm, event: Event) {
if (event.redacts.isNullOrBlank() || LocalEcho.isLocalEchoId(event.eventId.orEmpty())) {
return
}

View file

@ -46,7 +46,7 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
return eventType == EventType.REDACTION
}
override suspend fun process(realm: Realm, event: Event) {
override fun process(realm: Realm, event: Event) {
pruneEvent(realm, event)
}

View file

@ -30,7 +30,7 @@ import javax.inject.Inject
internal class RoomTombstoneEventProcessor @Inject constructor() : EventInsertLiveProcessor {
override suspend fun process(realm: Realm, event: Event) {
override fun process(realm: Realm, event: Event) {
if (event.roomId == null) return
val createRoomContent = event.getClearContent().toModel<RoomTombstoneContent>()
if (createRoomContent?.replacementRoomId == null) return

View file

@ -25,6 +25,8 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
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.events.model.getRelationContent
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.room.model.Membership
@ -49,6 +51,7 @@ 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
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
@ -486,23 +489,41 @@ internal class RoomSyncHandler @Inject constructor(
cryptoService.onLiveEvent(roomEntity.roomId, event, isInitialSync)
// Try to remove local echo
event.unsignedData?.transactionId?.also {
val sendingEventEntity = roomEntity.sendingTimelineEvents.find(it)
event.unsignedData?.transactionId?.also { txId ->
val sendingEventEntity = roomEntity.sendingTimelineEvents.find(txId)
if (sendingEventEntity != null) {
Timber.v("Remove local echo for tx:$it")
Timber.v("Remove local echo for tx:$txId")
roomEntity.sendingTimelineEvents.remove(sendingEventEntity)
if (event.isEncrypted() && event.content?.get("algorithm") as? String == MXCRYPTO_ALGORITHM_MEGOLM) {
// updated with echo decryption, to avoid seeing it decrypt again
// updated with echo decryption, to avoid seeing txId decrypt again
val adapter = MoshiProvider.providesMoshi().adapter<OlmDecryptionResult>(OlmDecryptionResult::class.java)
sendingEventEntity.root?.decryptionResultJson?.let { json ->
eventEntity.decryptionResultJson = json
event.mxDecryptionResult = adapter.fromJson(json)
}
}
// also update potential edit that could refer to that event?
// If not display will flicker :/
val relationContent = event.getRelationContent()
if (relationContent?.type == RelationType.REPLACE) {
relationContent.eventId?.let { targetId ->
EventAnnotationsSummaryEntity.where(realm, roomId, targetId)
.findFirst()
?.editSummary
?.editions
?.forEach {
if (it.eventId == txId) {
// just do that, the aggregation processor will to the rest
it.event = eventEntity
}
}
}
}
// Finally delete the local echo
sendingEventEntity.deleteOnCascade(true)
} else {
Timber.v("Can't find corresponding local echo for tx:$it")
Timber.v("Can't find corresponding local echo for tx:$txId")
}
}
}

View file

@ -22,7 +22,7 @@ import io.realm.RealmModel
import org.matrix.android.sdk.internal.database.awaitTransaction
import java.util.concurrent.atomic.AtomicReference
internal suspend fun <T> Monarchy.awaitTransaction(transaction: suspend (realm: Realm) -> T): T {
internal suspend fun <T> Monarchy.awaitTransaction(transaction: (realm: Realm) -> T): T {
return awaitTransaction(realmConfiguration, transaction)
}

View file

@ -0,0 +1,114 @@
/*
* 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 io.mockk.every
import io.mockk.mockk
import io.realm.RealmList
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldNotBe
import org.junit.Test
import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.EditionOfEvent
import org.matrix.android.sdk.internal.database.model.EventEntity
class EditAggregatedSummaryEntityMapperTest {
@Test
fun `test mapping summary entity to model`() {
val edits = RealmList<EditionOfEvent>(
EditionOfEvent(
timestamp = 0L,
eventId = "e0",
isLocalEcho = false,
event = mockEvent("e0")
),
EditionOfEvent(
timestamp = 1L,
eventId = "e1",
isLocalEcho = false,
event = mockEvent("e1")
),
EditionOfEvent(
timestamp = 30L,
eventId = "e2",
isLocalEcho = true,
event = mockEvent("e2")
)
)
val fakeSummaryEntity = mockk<EditAggregatedSummaryEntity> {
every { editions } returns edits
}
val mapped = EditAggregatedSummaryEntityMapper.map(fakeSummaryEntity)
mapped shouldNotBe null
mapped!!.sourceEvents.size shouldBeEqualTo 2
mapped.localEchos.size shouldBeEqualTo 1
mapped.localEchos.first() shouldBeEqualTo "e2"
mapped.lastEditTs shouldBeEqualTo 30L
mapped.latestEdit?.eventId shouldBeEqualTo "e2"
}
@Test
fun `event with lexicographically largest event_id is treated as more recent`() {
val lowerId = "\$Albatross"
val higherId = "\$Zebra"
(higherId > lowerId) shouldBeEqualTo true
val timestamp = 1669288766745L
val edits = RealmList<EditionOfEvent>(
EditionOfEvent(
timestamp = timestamp,
eventId = lowerId,
isLocalEcho = false,
event = mockEvent(lowerId)
),
EditionOfEvent(
timestamp = timestamp,
eventId = higherId,
isLocalEcho = false,
event = mockEvent(higherId)
),
EditionOfEvent(
timestamp = 1L,
eventId = "e2",
isLocalEcho = true,
event = mockEvent("e2")
)
)
val fakeSummaryEntity = mockk<EditAggregatedSummaryEntity> {
every { editions } returns edits
}
val mapped = EditAggregatedSummaryEntityMapper.map(fakeSummaryEntity)
mapped!!.lastEditTs shouldBeEqualTo timestamp
mapped.latestEdit?.eventId shouldBeEqualTo higherId
}
private fun mockEvent(eventId: String): EventEntity {
return EventEntity().apply {
this.eventId = eventId
this.content = """
{
"body" : "Hello",
"msgtype": "text"
}
""".trimIndent()
}
}
}

View file

@ -0,0 +1,134 @@
/*
* 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.event
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldNotBe
import org.junit.Test
import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
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.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
class ValidDecryptedEventTest {
private val fakeEvent = Event(
type = EventType.ENCRYPTED,
eventId = "\$eventId",
roomId = "!fakeRoom",
content = EncryptedEventContent(
algorithm = MXCRYPTO_ALGORITHM_MEGOLM,
ciphertext = "AwgBEpACQEKOkd4Gp0+gSXG4M+btcrnPgsF23xs/lUmS2I4YjmqF...",
sessionId = "TO2G4u2HlnhtbIJk",
senderKey = "5e3EIqg3JfooZnLQ2qHIcBarbassQ4qXblai0",
deviceId = "FAKEE"
).toContent()
)
@Test
fun `A failed to decrypt message should give a null validated decrypted event`() {
fakeEvent.toValidDecryptedEvent() shouldBe null
}
@Test
fun `Mismatch sender key detection`() {
val decryptedEvent = fakeEvent
.apply {
mxDecryptionResult = OlmDecryptionResult(
payload = mapOf(
"type" to EventType.MESSAGE,
"content" to mapOf(
"body" to "some message",
"msgtype" to "m.text"
),
),
senderKey = "the_real_sender_key",
)
}
val validDecryptedEvent = decryptedEvent.toValidDecryptedEvent()
validDecryptedEvent shouldNotBe null
fakeEvent.content!!["senderKey"] shouldNotBe "the_real_sender_key"
validDecryptedEvent!!.cryptoSenderKey shouldBe "the_real_sender_key"
}
@Test
fun `Mixed content event should be detected`() {
val mixedEvent = Event(
type = EventType.ENCRYPTED,
eventId = "\$eventd ",
roomId = "!fakeRoo",
content = mapOf(
"algorithm" to "m.megolm.v1.aes-sha2",
"ciphertext" to "AwgBEpACQEKOkd4Gp0+gSXG4M+btcrnPgsF23xs/lUmS2I4YjmqF...",
"sessionId" to "TO2G4u2HlnhtbIJk",
"senderKey" to "5e3EIqg3JfooZnLQ2qHIcBarbassQ4qXblai0",
"deviceId" to "FAKEE",
"body" to "some message",
"msgtype" to "m.text"
).toContent()
)
val unValidatedContent = mixedEvent.getClearContent().toModel<MessageTextContent>()
unValidatedContent?.body shouldBe "some message"
mixedEvent.toValidDecryptedEvent()?.clearContent?.toModel<MessageTextContent>() shouldBe null
}
@Test
fun `Basic field validation`() {
val decryptedEvent = fakeEvent
.apply {
mxDecryptionResult = OlmDecryptionResult(
payload = mapOf(
"type" to EventType.MESSAGE,
"content" to mapOf(
"body" to "some message",
"msgtype" to "m.text"
),
),
senderKey = "the_real_sender_key",
)
}
decryptedEvent.toValidDecryptedEvent() shouldNotBe null
decryptedEvent.copy(roomId = null).toValidDecryptedEvent() shouldBe null
decryptedEvent.copy(eventId = null).toValidDecryptedEvent() shouldBe null
}
@Test
fun `A clear event is not a valid decrypted event`() {
val mockTextEvent = Event(
type = EventType.MESSAGE,
eventId = "eventId",
roomId = "!fooe:example.com",
content = mapOf(
"body" to "some message",
"msgtype" to "m.text"
),
originServerTs = 1000,
senderId = "@anne:example.com",
)
mockTextEvent.toValidDecryptedEvent() shouldBe null
}
}

View file

@ -0,0 +1,372 @@
/*
* 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
import io.mockk.every
import io.mockk.mockk
import org.amshove.kluent.shouldBeInstanceOf
import org.junit.Test
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
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.internal.crypto.store.IMXCryptoStore
class EventEditValidatorTest {
private val mockTextEvent = Event(
type = EventType.MESSAGE,
eventId = "\$WX8WlNC2reiXrwHIA_CQHmU_pSR-jhOA2xKPRcJN9wQ",
roomId = "!GXKhWsRwiWWvbQDBpe:example.com",
content = mapOf(
"body" to "some message",
"msgtype" to "m.text"
),
originServerTs = 1000,
senderId = "@alice:example.com",
)
private val mockEdit = Event(
type = EventType.MESSAGE,
eventId = "\$-SF7RWLPzRzCbHqK3ZAhIrX5Auh3B2lS5AqJiypt1p0",
roomId = "!GXKhWsRwiWWvbQDBpe:example.com",
content = mapOf(
"body" to "* some message edited",
"msgtype" to "m.text",
"m.new_content" to mapOf(
"body" to "some message edited",
"msgtype" to "m.text"
),
"m.relates_to" to mapOf(
"rel_type" to "m.replace",
"event_id" to mockTextEvent.eventId
)
),
originServerTs = 2000,
senderId = "@alice:example.com",
)
@Test
fun `edit should be valid`() {
val mockCryptoStore = mockk<IMXCryptoStore>()
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(mockTextEvent, mockEdit) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class
}
@Test
fun `original event and replacement event must have the same sender`() {
val mockCryptoStore = mockk<IMXCryptoStore>()
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
mockTextEvent,
mockEdit.copy(senderId = "@bob:example.com")
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
}
@Test
fun `original event and replacement event must have the same room_id`() {
val mockCryptoStore = mockk<IMXCryptoStore>()
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
mockTextEvent,
mockEdit.copy(roomId = "!someotherroom")
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
validator
.validateEdit(
encryptedEvent,
encryptedEditEvent.copy(roomId = "!someotherroom")
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
}
@Test
fun `replacement and original events must not have a state_key property`() {
val mockCryptoStore = mockk<IMXCryptoStore>()
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
mockTextEvent,
mockEdit.copy(stateKey = "")
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
validator
.validateEdit(
mockTextEvent.copy(stateKey = ""),
mockEdit
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
}
@Test
fun `replacement event must have an new_content property`() {
val mockCryptoStore = mockk<IMXCryptoStore> {
every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns
mockk<CryptoDeviceInfo> {
every { userId } returns "@alice:example.com"
}
}
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(mockTextEvent, mockEdit.copy(
content = mockEdit.content!!.toMutableMap().apply {
this.remove("m.new_content")
}
)) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
validator
.validateEdit(
encryptedEvent,
encryptedEditEvent.copy().apply {
mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy(
payload = mapOf(
"type" to EventType.MESSAGE,
"content" to mapOf(
"body" to "* some message edited",
"msgtype" to "m.text",
"m.relates_to" to mapOf(
"rel_type" to "m.replace",
"event_id" to mockTextEvent.eventId
)
)
)
)
}
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
}
@Test
fun `The original event must not itself have a rel_type of m_replace`() {
val mockCryptoStore = mockk<IMXCryptoStore> {
every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns
mockk<CryptoDeviceInfo> {
every { userId } returns "@alice:example.com"
}
}
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
mockTextEvent.copy(
content = mockTextEvent.content!!.toMutableMap().apply {
this["m.relates_to"] = mapOf(
"rel_type" to "m.replace",
"event_id" to mockTextEvent.eventId
)
}
),
mockEdit
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
validator
.validateEdit(
encryptedEvent.copy(
content = encryptedEvent.content!!.toMutableMap().apply {
put(
"m.relates_to",
mapOf(
"rel_type" to "m.replace",
"event_id" to mockTextEvent.eventId
)
)
}
).apply {
mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy(
payload = mapOf(
"type" to EventType.MESSAGE,
"content" to mapOf(
"body" to "some message",
"msgtype" to "m.text",
),
)
)
},
encryptedEditEvent
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
}
@Test
fun `valid e2ee edit`() {
val mockCryptoStore = mockk<IMXCryptoStore> {
every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns
mockk<CryptoDeviceInfo> {
every { userId } returns "@alice:example.com"
}
}
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
encryptedEvent,
encryptedEditEvent
) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class
}
@Test
fun `If the original event was encrypted, the replacement should be too`() {
val mockCryptoStore = mockk<IMXCryptoStore> {
every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns
mockk<CryptoDeviceInfo> {
every { userId } returns "@alice:example.com"
}
}
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
encryptedEvent,
mockEdit
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
}
@Test
fun `encrypted, original event and replacement event must have the same sender`() {
val mockCryptoStore = mockk<IMXCryptoStore> {
every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns
mockk {
every { userId } returns "@alice:example.com"
}
every { deviceWithIdentityKey("7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI") } returns
mockk {
every { userId } returns "@bob:example.com"
}
}
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
encryptedEvent,
encryptedEditEvent.copy().apply {
mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy(
senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI"
)
}
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
// if sent fom a deleted device it should use the event claimed sender id
}
@Test
fun `encrypted, sent fom a deleted device, original event and replacement event must have the same sender`() {
val mockCryptoStore = mockk<IMXCryptoStore> {
every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns
mockk {
every { userId } returns "@alice:example.com"
}
every { deviceWithIdentityKey("7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI") } returns
null
}
val validator = EventEditValidator(mockCryptoStore)
validator
.validateEdit(
encryptedEvent,
encryptedEditEvent.copy().apply {
mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy(
senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI"
)
}
) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class
validator
.validateEdit(
encryptedEvent,
encryptedEditEvent.copy(
senderId = "bob@example.com"
).apply {
mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy(
senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI"
)
}
) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class
}
private val encryptedEditEvent = Event(
type = EventType.ENCRYPTED,
eventId = "\$-SF7RWLPzRzCbHqK3ZAhIrX5Auh3B2lS5AqJiypt1p0",
roomId = "!GXKhWsRwiWWvbQDBpe:example.com",
content = mapOf(
"algorithm" to "m.megolm.v1.aes-sha2",
"sender_key" to "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo",
"session_id" to "7tOd6xon/R2zJpy2LlSKcKWIek2jvkim0sNdnZZCWMQ",
"device_id" to "QDHBLWOTSN",
"ciphertext" to "AwgXErAC6TgQ4bV6NFldlffTWuUV1gsBYH6JLQMqG...deLfCQOSPunSSNDFdWuDkB8Cg",
"m.relates_to" to mapOf(
"rel_type" to "m.replace",
"event_id" to mockTextEvent.eventId
)
),
originServerTs = 2000,
senderId = "@alice:example.com",
).apply {
mxDecryptionResult = OlmDecryptionResult(
payload = mapOf(
"type" to EventType.MESSAGE,
"content" to mapOf(
"body" to "* some message edited",
"msgtype" to "m.text",
"m.new_content" to mapOf(
"body" to "some message edited",
"msgtype" to "m.text"
),
"m.relates_to" to mapOf(
"rel_type" to "m.replace",
"event_id" to mockTextEvent.eventId
)
)
),
senderKey = "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo",
isSafe = true
)
}
private val encryptedEvent = Event(
type = EventType.ENCRYPTED,
eventId = "\$WX8WlNC2reiXrwHIA_CQHmU_pSR-jhOA2xKPRcJN9wQ",
roomId = "!GXKhWsRwiWWvbQDBpe:example.com",
content = mapOf(
"algorithm" to "m.megolm.v1.aes-sha2",
"sender_key" to "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo",
"session_id" to "7tOd6xon/R2zJpy2LlSKcKWIek2jvkim0sNdnZZCWMQ",
"device_id" to "QDHBLWOTSN",
"ciphertext" to "AwgXErAC6TgQ4bV6NFldlffTWuUV1gsBYH6JLQMqG+4Vr...Yf0gYyhVWZY4SedF3fTMwkjmTuel4fwrmq",
),
originServerTs = 2000,
senderId = "@alice:example.com",
).apply {
mxDecryptionResult = OlmDecryptionResult(
payload = mapOf(
"type" to EventType.MESSAGE,
"content" to mapOf(
"body" to "some message",
"msgtype" to "m.text"
),
),
senderKey = "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo",
isSafe = true
)
}
}

View file

@ -23,8 +23,6 @@ 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.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
@ -226,16 +224,14 @@ class LocalEchoEventFactoryTests {
).toMessageTextContent().toContent()
}
return TimelineEvent(
root = A_START_EVENT,
root = A_START_EVENT.copy(
type = EventType.MESSAGE,
content = textContent
),
localId = 1234,
eventId = AN_EVENT_ID,
displayIndex = 0,
senderInfo = SenderInfo(A_USER_ID_1, A_USER_ID_1, true, null),
annotations = if (textContent != null) {
EventAnnotationsSummary(
editSummary = EditAggregatedSummary(latestContent = textContent, emptyList(), emptyList())
)
} else null
)
}
}

View file

@ -38,9 +38,9 @@ internal class FakeMonarchy {
init {
mockkStatic("org.matrix.android.sdk.internal.util.MonarchyKt")
coEvery {
instance.awaitTransaction(any<suspend (Realm) -> Any>())
} coAnswers {
secondArg<suspend (Realm) -> Any>().invoke(fakeRealm.instance)
instance.awaitTransaction(any<(Realm) -> Any>())
} answers {
secondArg<(Realm) -> Any>().invoke(fakeRealm.instance)
}
coEvery {
instance.doWithRealm(any())

View file

@ -19,7 +19,6 @@ package org.matrix.android.sdk.test.fakes
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.realm.Realm
import io.realm.RealmConfiguration
import org.matrix.android.sdk.internal.database.awaitTransaction
@ -33,9 +32,8 @@ internal class FakeRealmConfiguration {
val instance = mockk<RealmConfiguration>()
fun <T> givenAwaitTransaction(realm: Realm) {
val transaction = slot<suspend (Realm) -> T>()
coEvery { awaitTransaction(instance, capture(transaction)) } coAnswers {
secondArg<suspend (Realm) -> T>().invoke(realm)
coEvery { awaitTransaction(instance, any<(Realm) -> T>()) } answers {
secondArg<(Realm) -> T>().invoke(realm)
}
}
}

View file

@ -19,6 +19,7 @@ package im.vector.app.core.extensions
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.send.SendState
@ -40,7 +41,8 @@ fun TimelineEvent.getVectorLastMessageContent(): MessageContent? {
// Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method
return when (root.getClearType()) {
VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> {
(annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageVoiceBroadcastInfoContent>()
(annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessageContent>().toContent().toModel<MessageVoiceBroadcastInfoContent>()
?: root.getClearContent().toModel<MessageVoiceBroadcastInfoContent>())
}
else -> getLastMessageContent()
}

View file

@ -442,6 +442,7 @@ class TimelineEventController @Inject constructor(
val timelineEventsGroup = timelineEventsGroups.getOrNull(event)
val params = TimelineItemFactoryParams(
event = event,
lastEdit = event.annotations?.editSummary?.latestEdit,
prevEvent = prevEvent,
prevDisplayableEvent = prevDisplayableEvent,
nextEvent = nextEvent,

View file

@ -19,10 +19,12 @@ package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroup
import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
data class TimelineItemFactoryParams(
val event: TimelineEvent,
val lastEdit: Event? = null,
val prevEvent: TimelineEvent? = null,
val prevDisplayableEvent: TimelineEvent? = null,
val nextEvent: TimelineEvent? = null,

View file

@ -31,11 +31,13 @@ import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLay
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.verification.VerificationState
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.getMsgType
import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage
import org.matrix.android.sdk.api.session.events.model.isSticker
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent
import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageType
@ -72,8 +74,8 @@ class MessageInformationDataFactory @Inject constructor(
prevDisplayableEvent?.root?.localDateTime()?.toLocalDate() != date.toLocalDate()
val time = dateFormatter.format(event.root.originServerTs, DateFormatKind.MESSAGE_SIMPLE)
val e2eDecoration = getE2EDecoration(roomSummary, event)
val e2eDecoration = getE2EDecoration(roomSummary, params.lastEdit ?: event.root)
val senderId = getSenderId(event)
// SendState Decoration
val sendStateDecoration = if (isSentByMe) {
getSendStateDecoration(
@ -89,7 +91,7 @@ class MessageInformationDataFactory @Inject constructor(
return MessageInformationData(
eventId = eventId,
senderId = event.root.senderId ?: "",
senderId = senderId,
sendState = event.root.sendState,
time = time,
ageLocalTS = event.root.ageLocalTs,
@ -131,6 +133,14 @@ class MessageInformationDataFactory @Inject constructor(
)
}
private fun getSenderId(event: TimelineEvent) = if (event.isEncrypted()) {
event.root.toValidDecryptedEvent()?.let {
session.cryptoService().deviceWithIdentityKey(it.cryptoSenderKey, it.algorithm)?.userId
} ?: event.root.senderId.orEmpty()
} else {
event.root.senderId.orEmpty()
}
private fun getSendStateDecoration(
event: TimelineEvent,
lastSentEventWithoutReadReceipts: String?,
@ -148,34 +158,34 @@ class MessageInformationDataFactory @Inject constructor(
}
}
private fun getE2EDecoration(roomSummary: RoomSummary?, event: TimelineEvent): E2EDecoration {
private fun getE2EDecoration(roomSummary: RoomSummary?, event: Event): E2EDecoration {
if (roomSummary?.isEncrypted != true) {
// No decoration for clear room
// Questionable? what if the event is E2E?
return E2EDecoration.NONE
}
if (event.root.sendState != SendState.SYNCED) {
if (event.sendState != SendState.SYNCED) {
// we don't display e2e decoration if event not synced back
return E2EDecoration.NONE
}
val userCrossSigningInfo = session.cryptoService()
.crossSigningService()
.getUserCrossSigningKeys(event.root.senderId.orEmpty())
.getUserCrossSigningKeys(event.senderId.orEmpty())
if (userCrossSigningInfo?.isTrusted() == true) {
return if (event.isEncrypted()) {
// Do not decorate failed to decrypt, or redaction (we lost sender device info)
if (event.root.getClearType() == EventType.ENCRYPTED || event.root.isRedacted()) {
if (event.getClearType() == EventType.ENCRYPTED || event.isRedacted()) {
E2EDecoration.NONE
} else {
val sendingDevice = event.root.getSenderKey()
val sendingDevice = event.getSenderKey()
?.let {
session.cryptoService().deviceWithIdentityKey(
it,
event.root.content?.get("algorithm") as? String ?: ""
event.content?.get("algorithm") as? String ?: ""
)
}
if (event.root.mxDecryptionResult?.isSafe == false) {
if (event.mxDecryptionResult?.isSafe == false) {
E2EDecoration.WARN_UNSAFE_KEY
} else {
when {
@ -202,8 +212,8 @@ class MessageInformationDataFactory @Inject constructor(
} else {
return if (!event.isEncrypted()) {
e2EDecorationForClearEventInE2ERoom(event, roomSummary)
} else if (event.root.mxDecryptionResult != null) {
if (event.root.mxDecryptionResult?.isSafe == true) {
} else if (event.mxDecryptionResult != null) {
if (event.mxDecryptionResult?.isSafe == true) {
E2EDecoration.NONE
} else {
E2EDecoration.WARN_UNSAFE_KEY
@ -214,13 +224,13 @@ class MessageInformationDataFactory @Inject constructor(
}
}
private fun e2EDecorationForClearEventInE2ERoom(event: TimelineEvent, roomSummary: RoomSummary) =
if (event.root.isStateEvent()) {
private fun e2EDecorationForClearEventInE2ERoom(event: Event, roomSummary: RoomSummary) =
if (event.isStateEvent()) {
// Do not warn for state event, they are always in clear
E2EDecoration.NONE
} else {
val ts = roomSummary.encryptionEventTs ?: 0
val eventTs = event.root.originServerTs ?: 0
val eventTs = event.originServerTs ?: 0
// If event is in clear after the room enabled encryption we should warn
if (eventTs > ts) E2EDecoration.WARN_IN_CLEAR else E2EDecoration.NONE
}