diff --git a/CHANGES.md b/CHANGES.md
index b6fc44b4028513d1bd4bb0323dfdfd9380ddcb7c..26253bab8cba13535642a15bb354531b61eab1b2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,14 @@
 Please also refer to the Changelog of Element Android: https://github.com/vector-im/element-android/blob/main/CHANGES.md
 
+Changes in Matrix-SDK v1.5.7 (2022-11-16)
+=======================================
+
+Imported from Element 1.5.7. (https://github.com/vector-im/element-android/releases/tag/v1.5.7)
+
+SDK API changes ⚠️
+------------------
+- Add MetricPlugin interface to implement metrics in SDK clients. ([#7438](https://github.com/vector-im/element-android/issues/7438))
+
 Changes in Matrix-SDK v1.5.4 (2022-10-25)
 =======================================
 
diff --git a/dependencies.gradle b/dependencies.gradle
index f081e0a87439a6b2b33a9ba8c020f310fc198b3a..33a2096a4344e5d0d3da5fe175520378a9f58f15 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -1,5 +1,4 @@
 ext.versions = [
-
         'minSdk'            : 21,
         'compileSdk'        : 33,
         'targetSdk'         : 33,
@@ -12,7 +11,7 @@ def gradle = "7.3.1"
 def kotlin = "1.7.20"
 def kotlinCoroutines = "1.6.4"
 def dagger = "2.44"
-def appDistribution = "16.0.0-beta04"
+def appDistribution = "16.0.0-beta05"
 def retrofit = "2.9.0"
 def markwon = "4.6.2"
 def moshi = "1.14.0"
@@ -27,22 +26,20 @@ def jjwt = "0.11.5"
 // Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert
 // the whole commit which set version 0.16.0-SNAPSHOT
 def vanniktechEmoji = "0.16.0-SNAPSHOT"
-
-def sentry = "6.4.3"
-
-def fragment = "1.5.3"
-
+def sentry = "6.6.0"
+def fragment = "1.5.4"
 // Testing
 def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819
 def espresso = "3.4.0"
 def androidxTest = "1.4.0"
 def androidxOrchestrator = "1.4.1"
+def paparazzi = "1.1.0"
+
 ext.libs = [
         gradle      : [
                 'gradlePlugin'            : "com.android.tools.build:gradle:$gradle",
                 'kotlinPlugin'            : "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin",
                 'hiltPlugin'              : "com.google.dagger:hilt-android-gradle-plugin:$dagger"
-
         ],
         jetbrains   : [
                 'coroutinesCore'          : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutines",
@@ -50,12 +47,12 @@ ext.libs = [
                 'coroutinesTest'          : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutines"
         ],
         androidx    : [
-                'activity'                : "androidx.activity:activity-ktx:1.6.0",
+                'activity'                : "androidx.activity:activity-ktx:1.6.1",
                 'appCompat'               : "androidx.appcompat:appcompat:1.5.1",
                 'biometric'               : "androidx.biometric:biometric:1.1.0",
                 'core'                    : "androidx.core:core-ktx:1.9.0",
                 'recyclerview'            : "androidx.recyclerview:recyclerview:1.2.1",
-                'exifinterface'           : "androidx.exifinterface:exifinterface:1.3.4",
+                'exifinterface'           : "androidx.exifinterface:exifinterface:1.3.5",
                 'fragmentKtx'             : "androidx.fragment:fragment-ktx:$fragment",
                 'fragmentTesting'         : "androidx.fragment:fragment-testing:$fragment",
                 'constraintLayout'        : "androidx.constraintlayout:constraintlayout:2.1.4",
@@ -82,7 +79,7 @@ ext.libs = [
                 'transition'              : "androidx.transition:transition:1.2.0",
         ],
         google      : [
-                'material'                : "com.google.android.material:material:1.6.1",
+                'material'                : "com.google.android.material:material:1.7.0",
                 'appdistributionApi'      : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution",
                 'appdistribution'         : "com.google.firebase:firebase-appdistribution:$appDistribution",
                 // Phone number https://github.com/google/libphonenumber
@@ -108,6 +105,8 @@ ext.libs = [
                 'moshiKt'                : "com.squareup.moshi:moshi-kotlin:$moshi",
                 'moshiKotlin'            : "com.squareup.moshi:moshi-kotlin-codegen:$moshi",
                 'moshiAdapters'          : "com.squareup.moshi:moshi-adapters:$moshi",
+                'paparazzi'              : "app.cash.paparazzi:paparazzi:$paparazzi",
+                'paparazziPlugin'        : "app.cash.paparazzi:paparazzi-gradle-plugin:$paparazzi",
                 'retrofit'               : "com.squareup.retrofit2:retrofit:$retrofit",
                 'retrofitMoshi'          : "com.squareup.retrofit2:converter-moshi:$retrofit"
         ],
@@ -161,13 +160,13 @@ ext.libs = [
                 'emojiGoogle'            : "com.vanniktech:emoji-google:$vanniktechEmoji"
         ],
         apache      : [
-                'commonsImaging'         : "org.apache.sanselan:sanselan:0.97-incubator"
+                'commonsImaging'         : "org.apache.commons:commons-imaging:1.0-alpha3"
         ],
         sentry: [
                 'sentryAndroid'         : "io.sentry:sentry-android:$sentry"
         ],
         tests       : [
-                'kluent'                 : "org.amshove.kluent:kluent-android:1.68",
+                'kluent'                 : "org.amshove.kluent:kluent-android:1.72",
                 'timberJunitRule'        : "net.lachlanmckee:timber-junit-rule:1.0.1",
                 'junit'                  : "junit:junit:4.13.2",
         ]
diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle
index 0ee714f5e8ba3ac2b44c9034d763590bba18b909..9a29552a8ad96f5145f0dd4839634577ef996120 100644
--- a/dependencies_groups.gradle
+++ b/dependencies_groups.gradle
@@ -177,7 +177,6 @@ ext.groups = [
                         'org.apache.ant',
                         'org.apache.commons',
                         'org.apache.httpcomponents',
-                        'org.apache.sanselan',
                         'org.bouncycastle',
                         'org.ccil.cowan.tagsoup',
                         'org.checkerframework',
diff --git a/gradle.properties b/gradle.properties
index bc760114f3338ee003085f33b5006315ef45693f..d07487a737c0a9d959ac4064a4a24503a652068d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -26,7 +26,7 @@ vector.httpLogLevel=NONE
 # Ref: https://github.com/vanniktech/gradle-maven-publish-plugin
 GROUP=org.matrix.android
 POM_ARTIFACT_ID=matrix-android-sdk2
-VERSION_NAME=1.5.4
+VERSION_NAME=1.5.7
 
 POM_PACKAGING=aar
 
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 711956361797bf47068b030c31c272ee08c55f10..00d74ab446adfde5b1b1647aa10286cb317d1f62 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
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.api
 import okhttp3.ConnectionSpec
 import okhttp3.Interceptor
 import org.matrix.android.sdk.api.crypto.MXCryptoConfig
+import org.matrix.android.sdk.api.metrics.MetricPlugin
 import java.net.Proxy
 
 data class MatrixConfiguration(
@@ -74,4 +75,9 @@ data class MatrixConfiguration(
          * Sync configuration.
          */
         val syncConfig: SyncConfig = SyncConfig(),
+
+        /**
+         * Metrics plugin that can be used to capture metrics from matrix-sdk-android.
+         */
+        val metricPlugins: List<MetricPlugin> = emptyList()
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9487a270863047d41d2c4dcc23311ebdaea6e04b
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.extensions
+
+import org.matrix.android.sdk.api.metrics.MetricPlugin
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * Executes the given [block] while measuring the transaction.
+ */
+@OptIn(ExperimentalContracts::class)
+inline fun measureMetric(metricMeasurementPlugins: List<MetricPlugin>, block: () -> Unit) {
+    contract {
+        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+    }
+    try {
+        metricMeasurementPlugins.forEach { plugin -> plugin.startTransaction() } // Start the transaction.
+        block()
+    } catch (throwable: Throwable) {
+        metricMeasurementPlugins.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
+        throw throwable
+    } finally {
+        metricMeasurementPlugins.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction.
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt
index ae65963f37b26c3ccf654d3bf1ca8ea29998b792..22af8cebbdad9ca18e3830ecb7cf04433675b4e6 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt
@@ -27,6 +27,7 @@ open class LoggerTag(name: String, parentTag: LoggerTag? = null) {
     object SYNC : LoggerTag("SYNC")
     object VOIP : LoggerTag("VOIP")
     object CRYPTO : LoggerTag("CRYPTO")
+    object RENDEZVOUS : LoggerTag("RZ")
 
     val value: String = if (parentTag == null) {
         name
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/DownloadDeviceKeysMetricsPlugin.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/DownloadDeviceKeysMetricsPlugin.kt
new file mode 100644
index 0000000000000000000000000000000000000000..66ec0abf51a1c2559cf05ad4ba82bfd513967b2f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/DownloadDeviceKeysMetricsPlugin.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.metrics
+
+import org.matrix.android.sdk.api.logger.LoggerTag
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("DownloadKeysMetricsPlugin", LoggerTag.CRYPTO)
+
+/**
+ * Extension of MetricPlugin for download_device_keys task.
+ */
+interface DownloadDeviceKeysMetricsPlugin : MetricPlugin {
+
+    override fun logTransaction(message: String?) {
+        Timber.tag(loggerTag.value).v("## downloadDeviceKeysMetricPlugin() : $message")
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/MetricPlugin.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/MetricPlugin.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3a4b13c49402873d1d5b3be96948b83c5e7e7185
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/MetricPlugin.kt
@@ -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.metrics
+
+/**
+ * A plugin that can be used to capture metrics in Client.
+ */
+interface MetricPlugin {
+    /**
+     * Start the measurement of the metrics as soon as task is started.
+     */
+    fun startTransaction()
+
+    /**
+     * Mark the measuring transaction finished once the task is completed.
+     */
+    fun finishTransaction()
+
+    /**
+     * Invoked when there is any error in the ongoing task. The metrics tool can use this information to attach to the ongoing transaction.
+     *
+     * @param throwable Exception thrown in the running task.
+     */
+    fun onError(throwable: Throwable)
+
+    /**
+     * Can be used to log this transaction.
+     */
+    fun logTransaction(message: String? = "") {
+        // no-op
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f724ac4b62143a923837e413a2580b91c55fd303
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
@@ -0,0 +1,229 @@
+/*
+ * 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.rendezvous
+
+import android.net.Uri
+import org.matrix.android.sdk.api.auth.AuthenticationService
+import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
+import org.matrix.android.sdk.api.logger.LoggerTag
+import org.matrix.android.sdk.api.rendezvous.channels.ECDHRendezvousChannel
+import org.matrix.android.sdk.api.rendezvous.model.ECDHRendezvousCode
+import org.matrix.android.sdk.api.rendezvous.model.Outcome
+import org.matrix.android.sdk.api.rendezvous.model.Payload
+import org.matrix.android.sdk.api.rendezvous.model.PayloadType
+import org.matrix.android.sdk.api.rendezvous.model.Protocol
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousIntent
+import org.matrix.android.sdk.api.rendezvous.transports.SimpleHttpRendezvousTransport
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel
+import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME
+import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
+import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
+import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
+import org.matrix.android.sdk.api.util.MatrixJsonParser
+import timber.log.Timber
+
+/**
+ * Implementation of MSC3906 to sign in + E2EE set up using a QR code.
+ */
+class Rendezvous(
+        val channel: RendezvousChannel,
+        val theirIntent: RendezvousIntent,
+) {
+    companion object {
+        private val TAG = LoggerTag(Rendezvous::class.java.simpleName, LoggerTag.RENDEZVOUS).value
+
+        @Throws(RendezvousError::class)
+        fun buildChannelFromCode(code: String): Rendezvous {
+            val parsed = try {
+                // we rely on moshi validating the code and throwing exception if invalid JSON or doesn't
+                MatrixJsonParser.getMoshi().adapter(ECDHRendezvousCode::class.java).fromJson(code)
+            } catch (a: Throwable) {
+                throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
+            } ?: throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
+
+            val transport = SimpleHttpRendezvousTransport(parsed.rendezvous.transport.uri)
+
+            return Rendezvous(
+                    ECDHRendezvousChannel(transport, parsed.rendezvous.key),
+                    parsed.intent
+            )
+        }
+    }
+
+    private val adapter = MatrixJsonParser.getMoshi().adapter(Payload::class.java)
+
+    // not yet implemented: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE
+    val ourIntent: RendezvousIntent = RendezvousIntent.LOGIN_ON_NEW_DEVICE
+
+    @Throws(RendezvousError::class)
+    private suspend fun checkCompatibility() {
+        val incompatible = theirIntent == ourIntent
+
+        Timber.tag(TAG).d("ourIntent: $ourIntent, theirIntent: $theirIntent, incompatible: $incompatible")
+
+        if (incompatible) {
+            // inform the other side
+            send(Payload(PayloadType.FINISH, intent = ourIntent))
+            if (ourIntent == RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
+                throw RendezvousError("The other device isn't signed in", RendezvousFailureReason.OtherDeviceNotSignedIn)
+            } else {
+                throw RendezvousError("The other device is already signed in", RendezvousFailureReason.OtherDeviceAlreadySignedIn)
+            }
+        }
+    }
+
+    @Throws(RendezvousError::class)
+    suspend fun startAfterScanningCode(): String {
+        val checksum = channel.connect()
+
+        Timber.tag(TAG).i("Connected to secure channel with checksum: $checksum")
+
+        checkCompatibility()
+
+        // get protocols
+        Timber.tag(TAG).i("Waiting for protocols")
+        val protocolsResponse = receive()
+
+        if (protocolsResponse?.protocols == null || !protocolsResponse.protocols.contains(Protocol.LOGIN_TOKEN)) {
+            send(Payload(PayloadType.FINISH, outcome = Outcome.UNSUPPORTED))
+            throw RendezvousError("Unsupported protocols", RendezvousFailureReason.UnsupportedHomeserver)
+        }
+
+        send(Payload(PayloadType.PROGRESS, protocol = Protocol.LOGIN_TOKEN))
+
+        return checksum
+    }
+
+    @Throws(RendezvousError::class)
+    suspend fun waitForLoginOnNewDevice(authenticationService: AuthenticationService): Session {
+        Timber.tag(TAG).i("Waiting for login_token")
+
+        val loginToken = receive()
+
+        if (loginToken?.type == PayloadType.FINISH) {
+            when (loginToken.outcome) {
+                Outcome.DECLINED -> {
+                    throw RendezvousError("Login declined by other device", RendezvousFailureReason.UserDeclined)
+                }
+                Outcome.UNSUPPORTED -> {
+                    throw RendezvousError("Homeserver lacks support", RendezvousFailureReason.UnsupportedHomeserver)
+                }
+                else -> {
+                    throw RendezvousError("Unknown error", RendezvousFailureReason.Unknown)
+                }
+            }
+        }
+
+        val homeserver = loginToken?.homeserver ?: throw RendezvousError("No homeserver returned", RendezvousFailureReason.ProtocolError)
+        val token = loginToken.loginToken ?: throw RendezvousError("No login token returned", RendezvousFailureReason.ProtocolError)
+
+        Timber.tag(TAG).i("Got login_token now attempting to sign in with $homeserver")
+
+        val hsConfig = HomeServerConnectionConfig(homeServerUri = Uri.parse(homeserver))
+        return authenticationService.loginUsingQrLoginToken(hsConfig, token)
+    }
+
+    @Throws(RendezvousError::class)
+    suspend fun completeVerificationOnNewDevice(session: Session) {
+        val userId = session.myUserId
+        val crypto = session.cryptoService()
+        val deviceId = crypto.getMyDevice().deviceId
+        val deviceKey = crypto.getMyDevice().fingerprint()
+        send(Payload(PayloadType.PROGRESS, outcome = Outcome.SUCCESS, deviceId = deviceId, deviceKey = deviceKey))
+
+        // await confirmation of verification
+        val verificationResponse = receive()
+        if (verificationResponse?.outcome == Outcome.VERIFIED) {
+            val verifyingDeviceId = verificationResponse.verifyingDeviceId
+                    ?: throw RendezvousError("No verifying device id returned", RendezvousFailureReason.ProtocolError)
+            val verifyingDeviceFromServer = crypto.getCryptoDeviceInfo(userId, verifyingDeviceId)
+            if (verifyingDeviceFromServer?.fingerprint() != verificationResponse.verifyingDeviceKey) {
+                Timber.tag(TAG).w(
+                        "Verifying device $verifyingDeviceId key doesn't match: ${
+                            verifyingDeviceFromServer?.fingerprint()
+                        } vs ${verificationResponse.verifyingDeviceKey})"
+                )
+                // inform the other side
+                send(Payload(PayloadType.FINISH, outcome = Outcome.E2EE_SECURITY_ERROR))
+                throw RendezvousError("Key from verifying device doesn't match", RendezvousFailureReason.E2EESecurityIssue)
+            }
+
+            verificationResponse.masterKey?.let { masterKeyFromVerifyingDevice ->
+                // verifying device provided us with a master key, so use it to check integrity
+
+                // see what the homeserver told us
+                val localMasterKey = crypto.crossSigningService().getMyCrossSigningKeys()?.masterKey()
+
+                // n.b. if no local master key this is a problem, as well as it not matching
+                if (localMasterKey?.unpaddedBase64PublicKey != masterKeyFromVerifyingDevice) {
+                    Timber.tag(TAG).w("Master key from verifying device doesn't match: $masterKeyFromVerifyingDevice vs $localMasterKey")
+                    // inform the other side
+                    send(Payload(PayloadType.FINISH, outcome = Outcome.E2EE_SECURITY_ERROR))
+                    throw RendezvousError("Master key from verifying device doesn't match", RendezvousFailureReason.E2EESecurityIssue)
+                }
+
+                // set other device as verified
+                Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified")
+                crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId)
+
+                Timber.tag(TAG).i("Setting master key as trusted")
+                crypto.crossSigningService().markMyMasterKeyAsTrusted()
+            } ?: run {
+                // set other device as verified anyway
+                Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified")
+                crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId)
+
+                Timber.tag(TAG).i("No master key given by verifying device")
+            }
+
+            // request secrets from the verifying device
+            Timber.tag(TAG).i("Requesting secrets from $verifyingDeviceId")
+
+            session.sharedSecretStorageService().let {
+                it.requestSecret(MASTER_KEY_SSSS_NAME, verifyingDeviceId)
+                it.requestSecret(SELF_SIGNING_KEY_SSSS_NAME, verifyingDeviceId)
+                it.requestSecret(USER_SIGNING_KEY_SSSS_NAME, verifyingDeviceId)
+                it.requestSecret(KEYBACKUP_SECRET_SSSS_NAME, verifyingDeviceId)
+            }
+        } else {
+            Timber.tag(TAG).i("Not doing verification")
+        }
+    }
+
+    @Throws(RendezvousError::class)
+    private suspend fun receive(): Payload? {
+        val data = channel.receive() ?: return null
+        val payload = try {
+            adapter.fromJson(data.toString(Charsets.UTF_8))
+        } catch (e: Exception) {
+            Timber.tag(TAG).w(e, "Failed to parse payload")
+            throw RendezvousError("Invalid payload received", RendezvousFailureReason.Unknown)
+        }
+
+        return payload
+    }
+
+    private suspend fun send(payload: Payload) {
+        channel.send(adapter.toJson(payload).toByteArray(Charsets.UTF_8))
+    }
+
+    suspend fun close() {
+        channel.close()
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0956a5b0a0a1e011fafb8952f0e9a58e51e1e872
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.rendezvous
+
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
+
+/**
+ * Representation of a rendezvous channel such as that described by MSC3903.
+ */
+interface RendezvousChannel {
+    val transport: RendezvousTransport
+
+    /**
+     * @returns the checksum/confirmation digits to be shown to the user
+     */
+    @Throws(RendezvousError::class)
+    suspend fun connect(): String
+
+    /**
+     * Send a payload via the channel.
+     * @param data payload to send
+     */
+    @Throws(RendezvousError::class)
+    suspend fun send(data: ByteArray)
+
+    /**
+     * Receive a payload from the channel.
+     * @returns the received payload
+     */
+    @Throws(RendezvousError::class)
+    suspend fun receive(): ByteArray?
+
+    /**
+     * Closes the channel and cleans up.
+     */
+    suspend fun close()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt
new file mode 100644
index 0000000000000000000000000000000000000000..18e625d82599edccd477fc8b902a16f8e046fef7
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.rendezvous
+
+enum class RendezvousFailureReason(val canRetry: Boolean = true) {
+    UserDeclined,
+    OtherDeviceNotSignedIn,
+    OtherDeviceAlreadySignedIn,
+    Unknown,
+    Expired,
+    UserCancelled,
+    InvalidCode,
+    UnsupportedAlgorithm(false),
+    UnsupportedTransport(false),
+    UnsupportedHomeserver(false),
+    ProtocolError,
+    E2EESecurityIssue(false)
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt
new file mode 100644
index 0000000000000000000000000000000000000000..81632e951a786e6869a61743423811da5ec10000
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.rendezvous
+
+import okhttp3.MediaType
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousTransportDetails
+
+interface RendezvousTransport {
+    var ready: Boolean
+
+    @Throws(RendezvousError::class)
+    suspend fun details(): RendezvousTransportDetails
+
+    @Throws(RendezvousError::class)
+    suspend fun send(contentType: MediaType, data: ByteArray)
+
+    @Throws(RendezvousError::class)
+    suspend fun receive(): ByteArray?
+
+    suspend fun close()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c1d6b1b70ed47900edd07eabd3091c6459f9c8f7
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt
@@ -0,0 +1,183 @@
+/*
+ * 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.rendezvous.channels
+
+import android.util.Base64
+import com.squareup.moshi.JsonClass
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okhttp3.MediaType.Companion.toMediaType
+import org.matrix.android.sdk.api.logger.LoggerTag
+import org.matrix.android.sdk.api.rendezvous.RendezvousChannel
+import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
+import org.matrix.android.sdk.api.rendezvous.RendezvousTransport
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
+import org.matrix.android.sdk.api.rendezvous.model.SecureRendezvousChannelAlgorithm
+import org.matrix.android.sdk.api.util.MatrixJsonParser
+import org.matrix.android.sdk.internal.crypto.verification.SASDefaultVerificationTransaction
+import org.matrix.olm.OlmSAS
+import timber.log.Timber
+import java.security.SecureRandom
+import java.util.LinkedList
+import javax.crypto.Cipher
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+/**
+ *  Implements X25519 ECDH key agreement and AES-256-GCM encryption channel as per MSC3903:
+ *  https://github.com/matrix-org/matrix-spec-proposals/pull/3903
+ */
+class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPublicKeyBase64: String?) : RendezvousChannel {
+    companion object {
+        private const val ALGORITHM_SPEC = "AES/GCM/NoPadding"
+        private const val KEY_SPEC = "AES"
+        private val TAG = LoggerTag(ECDHRendezvousChannel::class.java.simpleName, LoggerTag.RENDEZVOUS).value
+    }
+
+    @JsonClass(generateAdapter = true)
+    internal data class ECDHPayload(
+            val algorithm: SecureRendezvousChannelAlgorithm? = null,
+            val key: String? = null,
+            val ciphertext: String? = null,
+            val iv: String? = null
+    )
+
+    private val olmSASMutex = Mutex()
+    private var olmSAS: OlmSAS?
+    private val ourPublicKey: ByteArray
+    private val ecdhAdapter = MatrixJsonParser.getMoshi().adapter(ECDHPayload::class.java)
+    private var theirPublicKey: ByteArray? = null
+    private var aesKey: ByteArray? = null
+
+    init {
+        theirPublicKeyBase64?.let {
+            theirPublicKey = Base64.decode(it, Base64.NO_WRAP)
+        }
+        olmSAS = OlmSAS()
+        ourPublicKey = Base64.decode(olmSAS!!.publicKey, Base64.NO_WRAP)
+    }
+
+    @Throws(RendezvousError::class)
+    override suspend fun connect(): String {
+        val sas = olmSAS ?: throw RendezvousError("Channel closed", RendezvousFailureReason.Unknown)
+        val isInitiator = theirPublicKey == null
+
+        if (isInitiator) {
+            Timber.tag(TAG).i("Waiting for other device to send their public key")
+            val res = this.receiveAsPayload() ?: throw RendezvousError("No reply from other device", RendezvousFailureReason.ProtocolError)
+
+            if (res.key == null) {
+                throw RendezvousError(
+                        "Unsupported algorithm: ${res.algorithm}",
+                        RendezvousFailureReason.UnsupportedAlgorithm,
+                )
+            }
+            theirPublicKey = Base64.decode(res.key, Base64.NO_WRAP)
+        } else {
+            // send our public key unencrypted
+            Timber.tag(TAG).i("Sending public key")
+            send(
+                    ECDHPayload(
+                            algorithm = SecureRendezvousChannelAlgorithm.ECDH_V1,
+                            key = Base64.encodeToString(ourPublicKey, Base64.NO_WRAP)
+                    )
+            )
+        }
+
+        olmSASMutex.withLock {
+            sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
+            sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
+
+            val initiatorKey = Base64.encodeToString(if (isInitiator) ourPublicKey else theirPublicKey, Base64.NO_WRAP)
+            val recipientKey = Base64.encodeToString(if (isInitiator) theirPublicKey else ourPublicKey, Base64.NO_WRAP)
+            val aesInfo = "${SecureRendezvousChannelAlgorithm.ECDH_V1.value}|$initiatorKey|$recipientKey"
+
+            aesKey = sas.generateShortCode(aesInfo, 32)
+
+            val rawChecksum = sas.generateShortCode(aesInfo, 5)
+            return SASDefaultVerificationTransaction.getDecimalCodeRepresentation(rawChecksum, separator = "-")
+        }
+    }
+
+    private suspend fun send(payload: ECDHPayload) {
+        transport.send("application/json".toMediaType(), ecdhAdapter.toJson(payload).toByteArray(Charsets.UTF_8))
+    }
+
+    override suspend fun send(data: ByteArray) {
+        if (aesKey == null) {
+            throw IllegalStateException("Shared secret not established")
+        }
+        send(encrypt(data))
+    }
+
+    private suspend fun receiveAsPayload(): ECDHPayload? {
+        transport.receive()?.toString(Charsets.UTF_8)?.let {
+            return ecdhAdapter.fromJson(it)
+        } ?: return null
+    }
+
+    override suspend fun receive(): ByteArray? {
+        if (aesKey == null) {
+            throw IllegalStateException("Shared secret not established")
+        }
+        val payload = receiveAsPayload() ?: return null
+        return decrypt(payload)
+    }
+
+    override suspend fun close() {
+        val sas = olmSAS ?: throw IllegalStateException("Channel already closed")
+        olmSASMutex.withLock {
+            // this does a double release check already so we don't re-check ourselves
+            sas.releaseSas()
+            olmSAS = null
+        }
+        transport.close()
+    }
+
+    private fun encrypt(plainText: ByteArray): ECDHPayload {
+        val iv = ByteArray(16)
+        SecureRandom().nextBytes(iv)
+
+        val cipherText = LinkedList<Byte>()
+
+        val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC)
+        val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC)
+        val ivParameterSpec = IvParameterSpec(iv)
+        encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec)
+        cipherText.addAll(encryptCipher.update(plainText).toList())
+        cipherText.addAll(encryptCipher.doFinal().toList())
+
+        return ECDHPayload(
+                ciphertext = Base64.encodeToString(cipherText.toByteArray(), Base64.NO_WRAP),
+                iv = Base64.encodeToString(iv, Base64.NO_WRAP)
+        )
+    }
+
+    private fun decrypt(payload: ECDHPayload): ByteArray {
+        val iv = Base64.decode(payload.iv, Base64.NO_WRAP)
+        val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC)
+        val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC)
+        val ivParameterSpec = IvParameterSpec(iv)
+        encryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)
+
+        val plainText = LinkedList<Byte>()
+        plainText.addAll(encryptCipher.update(Base64.decode(payload.ciphertext, Base64.NO_WRAP)).toList())
+        plainText.addAll(encryptCipher.doFinal().toList())
+
+        return plainText.toByteArray()
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt
new file mode 100644
index 0000000000000000000000000000000000000000..55bac6397e596a487ab99cd0e3dc80e19110b334
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class ECDHRendezvous(
+        val transport: SimpleHttpRendezvousTransportDetails,
+        val algorithm: SecureRendezvousChannelAlgorithm,
+        val key: String
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt
new file mode 100644
index 0000000000000000000000000000000000000000..575b5d4bfd3778b893002b4d8187c5e2dd5ead9d
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class ECDHRendezvousCode(
+        val intent: RendezvousIntent,
+        val rendezvous: ECDHRendezvous
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0ebd1f88b347616d3cd16e0ff0e42da10cfc710c
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = false)
+enum class Outcome(val value: String) {
+    @Json(name = "success")
+    SUCCESS("success"),
+
+    @Json(name = "declined")
+    DECLINED("declined"),
+
+    @Json(name = "unsupported")
+    UNSUPPORTED("unsupported"),
+
+    @Json(name = "verified")
+    VERIFIED("verified"),
+
+    @Json(name = "e2ee_security_error")
+    E2EE_SECURITY_ERROR("e2ee_security_error")
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt
new file mode 100644
index 0000000000000000000000000000000000000000..04631ce9599a5aa134997ab06ef4c5a62545b465
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+internal data class Payload(
+        val type: PayloadType,
+        val intent: RendezvousIntent? = null,
+        val outcome: Outcome? = null,
+        val protocols: List<Protocol>? = null,
+        val protocol: Protocol? = null,
+        val homeserver: String? = null,
+        @Json(name = "login_token") val loginToken: String? = null,
+        @Json(name = "device_id") val deviceId: String? = null,
+        @Json(name = "device_key") val deviceKey: String? = null,
+        @Json(name = "verifying_device_id") val verifyingDeviceId: String? = null,
+        @Json(name = "verifying_device_key") val verifyingDeviceKey: String? = null,
+        @Json(name = "master_key") val masterKey: String? = null
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt
new file mode 100644
index 0000000000000000000000000000000000000000..33beb1f5250758a35b4914c9119dd239efe78662
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = false)
+internal enum class PayloadType(val value: String) {
+    @Json(name = "m.login.start")
+    START("m.login.start"),
+
+    @Json(name = "m.login.finish")
+    FINISH("m.login.finish"),
+
+    @Json(name = "m.login.progress")
+    PROGRESS("m.login.progress")
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6fce2fa11c4b7b6b89d6bdf4d3be17379c255114
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = false)
+enum class Protocol(val value: String) {
+    @Json(name = "org.matrix.msc3906.login_token")
+    LOGIN_TOKEN("org.matrix.msc3906.login_token")
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c52b11a32261067c1f6be45de3993d459567bf15
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.rendezvous.model
+
+import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
+
+class RendezvousError(val description: String, val reason: RendezvousFailureReason) : Exception(description)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..65037e1252e3419c48eb15a1938813f8b656180d
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = false)
+enum class RendezvousIntent {
+    @Json(name = "login.start") LOGIN_ON_NEW_DEVICE,
+    @Json(name = "login.reciprocate") RECIPROCATE_LOGIN_ON_EXISTING_DEVICE
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1bde43ab7ea3003d69ce34fc2c0baed603534061
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+open class RendezvousTransportDetails(
+    val type: RendezvousTransportType
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6fca7efa7149add95ed4fb4ad45d812bcc601a1f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = false)
+enum class RendezvousTransportType(val value: String) {
+    @Json(name = "org.matrix.msc3886.http.v1")
+    MSC3886_SIMPLE_HTTP_V1("org.matrix.msc3886.http.v1")
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt
new file mode 100644
index 0000000000000000000000000000000000000000..75f0024fdac5124020223593a48a8e5b0cabee8c
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = false)
+enum class SecureRendezvousChannelAlgorithm(val value: String) {
+    @Json(name = "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256")
+    ECDH_V1("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256")
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt
new file mode 100644
index 0000000000000000000000000000000000000000..049aa8b756096bd496e7f71b31b85971133fe139
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.rendezvous.model
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class SimpleHttpRendezvousTransportDetails(
+        val uri: String
+) : RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt
new file mode 100644
index 0000000000000000000000000000000000000000..620b599e3df0e4a9fa85aa8ee64e584dc1cfa3bb
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt
@@ -0,0 +1,173 @@
+/*
+ * 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.rendezvous.transports
+
+import kotlinx.coroutines.delay
+import okhttp3.MediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.matrix.android.sdk.api.logger.LoggerTag
+import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
+import org.matrix.android.sdk.api.rendezvous.RendezvousTransport
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousTransportDetails
+import org.matrix.android.sdk.api.rendezvous.model.SimpleHttpRendezvousTransportDetails
+import timber.log.Timber
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Implementation of the Simple HTTP transport MSC3886: https://github.com/matrix-org/matrix-spec-proposals/pull/3886
+ */
+class SimpleHttpRendezvousTransport(rendezvousUri: String?) : RendezvousTransport {
+    companion object {
+        private val TAG = LoggerTag(SimpleHttpRendezvousTransport::class.java.simpleName, LoggerTag.RENDEZVOUS).value
+    }
+
+    override var ready = false
+    private var cancelled = false
+    private var uri: String?
+    private var etag: String? = null
+    private var expiresAt: Date? = null
+
+    init {
+        uri = rendezvousUri
+    }
+
+    override suspend fun details(): RendezvousTransportDetails {
+        val uri = uri ?: throw IllegalStateException("Rendezvous not set up")
+
+        return SimpleHttpRendezvousTransportDetails(uri)
+    }
+
+    @Throws(RendezvousError::class)
+    override suspend fun send(contentType: MediaType, data: ByteArray) {
+        if (cancelled) {
+            throw IllegalStateException("Rendezvous cancelled")
+        }
+
+        val method = if (uri != null) "PUT" else "POST"
+        val uri = this.uri ?: throw RuntimeException("No rendezvous URI")
+
+        val httpClient = okhttp3.OkHttpClient.Builder().build()
+
+        val request = Request.Builder()
+                .url(uri)
+                .method(method, data.toRequestBody())
+                .header("content-type", contentType.toString())
+
+        etag?.let {
+            request.header("if-match", it)
+        }
+
+        val response = httpClient.newCall(request.build()).execute()
+
+        if (response.code == 404) {
+            throw get404Error()
+        }
+        etag = response.header("etag")
+
+        Timber.tag(TAG).i("Sent data to $uri new etag $etag")
+
+        if (method == "POST") {
+            val location = response.header("location") ?: throw RuntimeException("No rendezvous URI found in response")
+
+            response.header("expires")?.let {
+                val format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
+                expiresAt = format.parse(it)
+            }
+
+            // resolve location header which could be relative or absolute
+            this.uri = response.request.url.toUri().resolve(location).toString()
+            ready = true
+        }
+    }
+
+    @Throws(RendezvousError::class)
+    override suspend fun receive(): ByteArray? {
+        if (cancelled) {
+            throw IllegalStateException("Rendezvous cancelled")
+        }
+        val uri = uri ?: throw IllegalStateException("Rendezvous not set up")
+        val httpClient = okhttp3.OkHttpClient.Builder().build()
+        while (true) {
+            Timber.tag(TAG).i("Polling: $uri after etag $etag")
+            val request = Request.Builder()
+                    .url(uri)
+                    .get()
+
+            etag?.let {
+                request.header("if-none-match", it)
+            }
+
+            val response = httpClient.newCall(request.build()).execute()
+
+            try {
+                // expired
+                if (response.code == 404) {
+                    throw get404Error()
+                }
+
+                // rely on server expiring the channel rather than checking ourselves
+
+                if (response.header("content-type") != "application/json") {
+                    response.header("etag")?.let {
+                        etag = it
+                    }
+                } else if (response.code == 200) {
+                    response.header("etag")?.let {
+                        etag = it
+                    }
+                    return response.body?.bytes()
+                }
+
+                // sleep for a second before polling again
+                // we rely on the server expiring the channel rather than checking it ourselves
+                delay(1000)
+            } finally {
+                response.close()
+            }
+        }
+    }
+
+    private fun get404Error(): RendezvousError {
+        if (expiresAt != null && Date() > expiresAt) {
+            return RendezvousError("Expired", RendezvousFailureReason.Expired)
+        }
+
+        return RendezvousError("Received unexpected 404", RendezvousFailureReason.Unknown)
+    }
+
+    override suspend fun close() {
+        cancelled = true
+        ready = false
+
+        uri?.let {
+            try {
+                val httpClient = okhttp3.OkHttpClient.Builder().build()
+                val request = Request.Builder()
+                        .url(it)
+                        .delete()
+                        .build()
+                httpClient.newCall(request).execute()
+            } catch (e: Throwable) {
+                Timber.tag(TAG).w(e, "Failed to delete channel")
+            }
+        }
+    }
+}
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 71daf4cc4f2fada741fe69d3882b1630b918f53b..1f16041b543f480834d2775aae7e9997bc0d9c8f 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
@@ -401,7 +401,7 @@ fun Event.getRelationContent(): RelationDefaultContent? {
             when (getClearType()) {
                 EventType.STICKER -> getClearContent().toModel<MessageStickerContent>()?.relatesTo
                 in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel<MessageBeaconLocationDataContent>()?.relatesTo
-                else -> null
+                else -> getClearContent()?.get("m.relates_to")?.toContent().toModel()
             }
         }
     }
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 8c14ca892aae1366229f69bc75f845664e8df44e..773e870ffd9e329d274849dab89a04c73afad283 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
@@ -65,6 +65,11 @@ data class HomeServerCapabilities(
          * True if the home server supports login via qr code, false otherwise.
          */
         val canLoginWithQrCode: Boolean = false,
+
+        /**
+         * True if the home server supports threaded read receipts and unread notifications.
+         */
+        val canUseThreadReadReceiptsAndNotifications: Boolean = false,
 ) {
 
     enum class RoomCapabilitySupport {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt
index ff4977491fc8524a35fdfc0d1b6dece901fdb1de..b63656dc50b7a4dae289bf67e6cf8aa6805c6fa7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt
@@ -97,6 +97,14 @@ data class RoomSummary(
          * Number of unread and highlighted message in this room.
          */
         val highlightCount: Int = 0,
+        /**
+         * Number of threads with unread messages in this room.
+         */
+        val threadNotificationCount: Int = 0,
+        /**
+         * Number of threads with highlighted messages in this room.
+         */
+        val threadHighlightCount: Int = 0,
         /**
          * True if this room has unread messages.
          */
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt
index 1824d5dc6c587b950ce28bbf9aa0ebc9f6e0fec8..9ac33c0545143e1d2cbdd3238eb6a19d9de51b25 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt
@@ -106,6 +106,8 @@ interface Timeline {
 
         /**
          * Called when new events come through the sync.
+         * Note that the corresponding events may not be available yet in the database.
+         * [onTimelineUpdated] will be called with the event content.
          */
         fun onNewTimelineEvents(eventIds: List<String>) = Unit
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/model/RoomSync.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/model/RoomSync.kt
index e5ac0a39b234710e8cf206aa750c7449efbdfe8d..7eb44c1350afd57c4af4f0b1a2ff37cc910f24b9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/model/RoomSync.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/model/RoomSync.kt
@@ -47,6 +47,11 @@ data class RoomSync(
          */
         @Json(name = "unread_notifications") val unreadNotifications: RoomSyncUnreadNotifications? = null,
 
+        /**
+         * The count of threads with unread notifications (not the total # of notifications in all threads).
+         */
+        @Json(name = "unread_thread_notifications") val unreadThreadNotifications: Map<String, RoomSyncUnreadThreadNotifications>? = null,
+
         /**
          * The room summary.
          */
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/model/RoomSyncUnreadThreadNotifications.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/model/RoomSyncUnreadThreadNotifications.kt
new file mode 100644
index 0000000000000000000000000000000000000000..70524d299aa9ce5fd33dd0bb86719c7cf10a307d
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/model/RoomSyncUnreadThreadNotifications.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.sync.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class RoomSyncUnreadThreadNotifications(
+        /**
+         * The number of threads with unread messages that match the push notification rules.
+         */
+        @Json(name = "notification_count") val notificationCount: Int? = null,
+
+        /**
+         * The number of threads with highlighted unread messages (subset of notifications).
+         */
+        @Json(name = "highlight_count") val highlightCount: Int? = null
+)
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 75639c6a218d97e04fe1f2a3212d2eb75a980168..d443d6e3c8c410e166d2900dac4fdd40667feb2c 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
@@ -60,5 +60,6 @@ internal data class HomeServerVersion(
         val r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0)
         val r0_6_1 = HomeServerVersion(major = 0, minor = 6, patch = 1)
         val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0)
+        val v1_4_0 = HomeServerVersion(major = 1, minor = 4, 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 5e133fab9ccaa0c3e627eb4107f51f3a535a6cd1..1245d8df4b6b1d4d0b1399cc5c80edf5e06c04ef 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
@@ -54,6 +54,8 @@ 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"
 private const val FEATURE_QR_CODE_LOGIN = "org.matrix.msc3882"
+private const val FEATURE_THREADS_MSC3771 = "org.matrix.msc3771"
+private const val FEATURE_THREADS_MSC3773 = "org.matrix.msc3773"
 
 /**
  * Return true if the SDK supports this homeserver version.
@@ -79,6 +81,15 @@ internal fun Versions.doesServerSupportThreads(): Boolean {
     return unstableFeatures?.get(FEATURE_THREADS_MSC3440_STABLE) ?: false
 }
 
+/**
+ * Indicate if the homeserver support MSC3771 and MSC3773 for threaded read receipts and unread notifications.
+ */
+internal fun Versions.doesServerSupportThreadUnreadNotifications(): Boolean {
+    val msc3771 = unstableFeatures?.get(FEATURE_THREADS_MSC3771) ?: false
+    val msc3773 = unstableFeatures?.get(FEATURE_THREADS_MSC3773) ?: false
+    return getMaxVersion() >= HomeServerVersion.v1_4_0 || (msc3771 && msc3773)
+}
+
 internal fun Versions.doesServerSupportQrCodeLogin(): Boolean {
     return unstableFeatures?.get(FEATURE_QR_CODE_LOGIN) ?: false
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt
index 4f3900adb95a9e95e1e78f7becb26b662f665e09..2ac6b8c85413fd86feb035752db69ca9df2bcbf0 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt
@@ -18,13 +18,17 @@ package org.matrix.android.sdk.internal.crypto
 
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.launch
+import org.matrix.android.sdk.api.MatrixConfiguration
 import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
 import org.matrix.android.sdk.api.MatrixPatterns
 import org.matrix.android.sdk.api.auth.data.Credentials
+import org.matrix.android.sdk.api.extensions.measureMetric
+import org.matrix.android.sdk.api.metrics.DownloadDeviceKeysMetricsPlugin
 import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel
 import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
 import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
 import org.matrix.android.sdk.internal.crypto.model.CryptoInfoMapper
+import org.matrix.android.sdk.internal.crypto.model.rest.KeysQueryResponse
 import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
 import org.matrix.android.sdk.internal.crypto.tasks.DownloadKeysForUsersTask
 import org.matrix.android.sdk.internal.session.SessionScope
@@ -47,8 +51,11 @@ internal class DeviceListManager @Inject constructor(
         coroutineDispatchers: MatrixCoroutineDispatchers,
         private val taskExecutor: TaskExecutor,
         private val clock: Clock,
+        matrixConfiguration: MatrixConfiguration
 ) {
 
+    private val metricPlugins = matrixConfiguration.metricPlugins
+
     interface UserDevicesUpdateListener {
         fun onUsersDeviceUpdate(userIds: List<String>)
     }
@@ -345,19 +352,25 @@ internal class DeviceListManager @Inject constructor(
             return MXUsersDevicesMap()
         }
         val params = DownloadKeysForUsersTask.Params(filteredUsers, syncTokenStore.getLastToken())
-        val response = try {
-            downloadKeysForUsersTask.execute(params)
-        } catch (throwable: Throwable) {
-            Timber.e(throwable, "## CRYPTO | doKeyDownloadForUsers(): error")
-            if (throwable is CancellationException) {
-                // the crypto module is getting closed, so we cannot access the DB anymore
-                Timber.w("The crypto module is closed, ignoring this error")
-            } else {
-                onKeysDownloadFailed(filteredUsers)
+        val relevantPlugins = metricPlugins.filterIsInstance<DownloadDeviceKeysMetricsPlugin>()
+
+        val response: KeysQueryResponse
+        measureMetric(relevantPlugins) {
+            response = try {
+                downloadKeysForUsersTask.execute(params)
+            } catch (throwable: Throwable) {
+                Timber.e(throwable, "## CRYPTO | doKeyDownloadForUsers(): error")
+                if (throwable is CancellationException) {
+                    // the crypto module is getting closed, so we cannot access the DB anymore
+                    Timber.w("The crypto module is closed, ignoring this error")
+                } else {
+                    onKeysDownloadFailed(filteredUsers)
+                }
+                throw throwable
             }
-            throw throwable
+            Timber.v("## CRYPTO | doKeyDownloadForUsers() : Got keys for " + filteredUsers.size + " users")
         }
-        Timber.v("## CRYPTO | doKeyDownloadForUsers() : Got keys for " + filteredUsers.size + " users")
+
         for (userId in filteredUsers) {
             // al devices =
             val models = response.deviceKeys?.get(userId)?.mapValues { entry -> CryptoInfoMapper.map(entry.value) }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt
index 1cbaff059aed85ba43437f2f9f386a157cc79da4..29b416bb82c535ef3cb50a051a9c8e8a27501257 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt
@@ -82,6 +82,33 @@ internal abstract class SASDefaultVerificationTransaction(
         // older devices have limited support of emoji but SDK offers images for the 64 verification emojis
         // so always send that we support EMOJI
         val KNOWN_SHORT_CODES = listOf(SasMode.EMOJI, SasMode.DECIMAL)
+
+        /**
+         * decimal: generate five bytes by using HKDF.
+         * Take the first 13 bits and convert it to a decimal number (which will be a number between 0 and 8191 inclusive),
+         * and add 1000 (resulting in a number between 1000 and 9191 inclusive).
+         * Do the same with the second 13 bits, and the third 13 bits, giving three 4-digit numbers.
+         * In other words, if the five bytes are B0, B1, B2, B3, and B4, then the first number is (B0 << 5 | B1 >> 3) + 1000,
+         * the second number is ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000, and the third number is ((B3 & 0x3f) << 7 | B4 >> 1) + 1000.
+         * (This method of converting 13 bits at a time is used to avoid requiring 32-bit clients to do big-number arithmetic,
+         * and adding 1000 to the number avoids having clients to worry about properly zero-padding the number when displaying to the user.)
+         * The three 4-digit numbers are displayed to the user either with dashes (or another appropriate separator) separating the three numbers,
+         * or with the three numbers on separate lines.
+         */
+        fun getDecimalCodeRepresentation(byteArray: ByteArray, separator: String = " "): String {
+            val b0 = byteArray[0].toUnsignedInt() // need unsigned byte
+            val b1 = byteArray[1].toUnsignedInt() // need unsigned byte
+            val b2 = byteArray[2].toUnsignedInt() // need unsigned byte
+            val b3 = byteArray[3].toUnsignedInt() // need unsigned byte
+            val b4 = byteArray[4].toUnsignedInt() // need unsigned byte
+            // (B0 << 5 | B1 >> 3) + 1000
+            val first = (b0.shl(5) or b1.shr(3)) + 1000
+            // ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000
+            val second = ((b1 and 0x7).shl(10) or b2.shl(2) or b3.shr(6)) + 1000
+            // ((B3 & 0x3f) << 7 | B4 >> 1) + 1000
+            val third = ((b3 and 0x3f).shl(7) or b4.shr(1)) + 1000
+            return "$first$separator$second$separator$third"
+        }
     }
 
     override var state: VerificationTxState = VerificationTxState.None
@@ -371,33 +398,6 @@ internal abstract class SASDefaultVerificationTransaction(
         return getDecimalCodeRepresentation(shortCodeBytes!!)
     }
 
-    /**
-     * decimal: generate five bytes by using HKDF.
-     * Take the first 13 bits and convert it to a decimal number (which will be a number between 0 and 8191 inclusive),
-     * and add 1000 (resulting in a number between 1000 and 9191 inclusive).
-     * Do the same with the second 13 bits, and the third 13 bits, giving three 4-digit numbers.
-     * In other words, if the five bytes are B0, B1, B2, B3, and B4, then the first number is (B0 << 5 | B1 >> 3) + 1000,
-     * the second number is ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000, and the third number is ((B3 & 0x3f) << 7 | B4 >> 1) + 1000.
-     * (This method of converting 13 bits at a time is used to avoid requiring 32-bit clients to do big-number arithmetic,
-     * and adding 1000 to the number avoids having clients to worry about properly zero-padding the number when displaying to the user.)
-     * The three 4-digit numbers are displayed to the user either with dashes (or another appropriate separator) separating the three numbers,
-     * or with the three numbers on separate lines.
-     */
-    fun getDecimalCodeRepresentation(byteArray: ByteArray): String {
-        val b0 = byteArray[0].toUnsignedInt() // need unsigned byte
-        val b1 = byteArray[1].toUnsignedInt() // need unsigned byte
-        val b2 = byteArray[2].toUnsignedInt() // need unsigned byte
-        val b3 = byteArray[3].toUnsignedInt() // need unsigned byte
-        val b4 = byteArray[4].toUnsignedInt() // need unsigned byte
-        // (B0 << 5 | B1 >> 3) + 1000
-        val first = (b0.shl(5) or b1.shr(3)) + 1000
-        // ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000
-        val second = ((b1 and 0x7).shl(10) or b2.shl(2) or b3.shr(6)) + 1000
-        // ((B3 & 0x3f) << 7 | B4 >> 1) + 1000
-        val third = ((b3 and 0x3f).shl(7) or b4.shr(1)) + 1000
-        return "$first $second $third"
-    }
-
     override fun getEmojiCodeRepresentation(): List<EmojiRepresentation> {
         return getEmojiCodeRepresentation(shortCodeBytes!!)
     }
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 9a2c32f97cdd982f677ef8b0ca6acd191f0dafbd..58c015b13b63c4ac9f98f6c82980f6e83ed912eb 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
@@ -56,6 +56,8 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo036
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo037
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo038
 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.util.Normalizer
 import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
 import javax.inject.Inject
@@ -64,7 +66,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
         private val normalizer: Normalizer
 ) : MatrixRealmMigration(
         dbName = "Session",
-        schemaVersion = 39L,
+        schemaVersion = 41L,
 ) {
     /**
      * Forces all RealmSessionStoreMigration instances to be equal.
@@ -113,5 +115,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
         if (oldVersion < 37) MigrateSessionTo037(realm).perform()
         if (oldVersion < 38) MigrateSessionTo038(realm).perform()
         if (oldVersion < 39) MigrateSessionTo039(realm).perform()
+        if (oldVersion < 40) MigrateSessionTo040(realm).perform()
+        if (oldVersion < 41) MigrateSessionTo041(realm).perform()
     }
 }
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 63fa101c45ce9c3f19f465090d49d2999d5cf474..3528ca0051de21b6f207201e1e95f4b6d8ec6966 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
@@ -45,6 +45,7 @@ internal object HomeServerCapabilitiesMapper {
                 canUseThreading = entity.canUseThreading,
                 canControlLogoutDevices = entity.canControlLogoutDevices,
                 canLoginWithQrCode = entity.canLoginWithQrCode,
+                canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications
         )
     }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt
index 72b0f7a0432022af7cf36dfd4bec4a6309fc1937..6e9fff78e1cd0f435f5e14715535bfeb65ea2b99 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt
@@ -61,6 +61,8 @@ internal class RoomSummaryMapper @Inject constructor(
                 otherMemberIds = roomSummaryEntity.otherMemberIds.toList(),
                 highlightCount = roomSummaryEntity.highlightCount,
                 notificationCount = roomSummaryEntity.notificationCount,
+                threadHighlightCount = roomSummaryEntity.threadHighlightCount,
+                threadNotificationCount = roomSummaryEntity.threadNotificationCount,
                 hasUnreadMessages = roomSummaryEntity.hasUnreadMessages,
                 tags = tags,
                 typingUsers = typingUsers,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo040.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo040.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b3e02342dd1a99bc343d0bc86e426bc710a79eeb
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo040.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.HomeServerCapabilitiesEntityFields
+import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo040(realm: DynamicRealm) : RealmMigrator(realm, 40) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("HomeServerCapabilitiesEntity")
+                ?.addField(HomeServerCapabilitiesEntityFields.CAN_USE_THREAD_READ_RECEIPTS_AND_NOTIFICATIONS, Boolean::class.java)
+                ?.transform { obj ->
+                    obj.set(HomeServerCapabilitiesEntityFields.CAN_USE_THREAD_READ_RECEIPTS_AND_NOTIFICATIONS, false)
+                }
+                ?.forceRefreshOfHomeServerCapabilities()
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo041.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo041.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b58d80e50ab9f7a22272142f3505bafd9cb8fe44
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo041.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.RoomSummaryEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo041(realm: DynamicRealm) : RealmMigrator(realm, 41) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("RoomSummaryEntity")
+                ?.addField(RoomSummaryEntityFields.THREAD_HIGHLIGHT_COUNT, Int::class.java)
+                ?.addField(RoomSummaryEntityFields.THREAD_NOTIFICATION_COUNT, Int::class.java)
+    }
+}
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 cfa02b2c743df39ed1eb67350f12ed53f5f6888a..89f1e50b30997272cb7babaa25ae0862798cabb4 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
@@ -32,6 +32,7 @@ internal open class HomeServerCapabilitiesEntity(
         var canUseThreading: Boolean = false,
         var canControlLogoutDevices: Boolean = false,
         var canLoginWithQrCode: Boolean = false,
+        var canUseThreadReadReceiptsAndNotifications: Boolean = false,
 ) : RealmObject() {
 
     companion object
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt
index 471bec59afe7d9bb9737e97b3b014dd6e4a36aca..650dd3c5cb99626cb7d48a508721c09950b59452 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt
@@ -115,6 +115,16 @@ internal open class RoomSummaryEntity(
             if (value != field) field = value
         }
 
+    var threadNotificationCount: Int = 0
+        set(value) {
+            if (value != field) field = value
+        }
+
+    var threadHighlightCount: Int = 0
+        set(value) {
+            if (value != field) field = value
+        }
+
     var readMarkerId: String? = null
         set(value) {
             if (value != field) field = value
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 30010f90fd605213a08687f739b7df2722d65d5b..1b4b3599165622664175c5b1b7bcf3731e51a0db 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
@@ -110,8 +110,7 @@ internal fun RealmQuery<TimelineEventEntity>.filterEvents(filters: TimelineEvent
         endGroup()
     }
     if (filters.filterUseless) {
-        not()
-                .equalTo(TimelineEventEntityFields.ROOT.IS_USELESS, true)
+        not().equalTo(TimelineEventEntityFields.ROOT.IS_USELESS, true)
     }
     if (filters.filterEdits) {
         not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.EDIT)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt
index 3fa9ffb0e118371a050196e6158c6ea3527579d6..1531d700831ce106f29be869f880bfd74610054a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageExifTagRemover.kt
@@ -17,11 +17,11 @@
 package org.matrix.android.sdk.internal.session.content
 
 import kotlinx.coroutines.withContext
-import org.apache.sanselan.Sanselan
-import org.apache.sanselan.formats.jpeg.JpegImageMetadata
-import org.apache.sanselan.formats.jpeg.exifRewrite.ExifRewriter
-import org.apache.sanselan.formats.tiff.constants.ExifTagConstants
-import org.apache.sanselan.formats.tiff.constants.GPSTagConstants
+import org.apache.commons.imaging.Imaging
+import org.apache.commons.imaging.formats.jpeg.JpegImageMetadata
+import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter
+import org.apache.commons.imaging.formats.tiff.constants.ExifTagConstants
+import org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants
 import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
 import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.internal.util.TemporaryFileCreator
@@ -46,24 +46,13 @@ internal class ImageExifTagRemover @Inject constructor(
      */
     suspend fun removeSensitiveJpegExifTags(jpegImageFile: File): File = withContext(coroutineDispatchers.io) {
         val outputSet = tryOrNull("Unable to read JpegImageMetadata") {
-            (Sanselan.getMetadata(jpegImageFile) as? JpegImageMetadata)?.exif?.outputSet
+            (Imaging.getMetadata(jpegImageFile) as? JpegImageMetadata)?.exif?.outputSet
         } ?: return@withContext jpegImageFile
 
         tryOrNull("Unable to remove ExifData") {
-            outputSet.removeField(ExifTagConstants.EXIF_TAG_GPSINFO)
-            outputSet.removeField(ExifTagConstants.EXIF_TAG_SUBJECT_LOCATION_1)
-            outputSet.removeField(ExifTagConstants.EXIF_TAG_SUBJECT_LOCATION_2)
-            outputSet.removeField(ExifTagConstants.EXIF_TAG_USER_COMMENT)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_ALTITUDE)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_ALTITUDE_REF)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LONGITUDE)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LONGITUDE_REF)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LONGITUDE)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LONGITUDE_REF)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LATITUDE)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_LATITUDE_REF)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LATITUDE)
-            outputSet.removeField(GPSTagConstants.GPS_TAG_GPS_DEST_LATITUDE_REF)
+            tagsToRemove.forEach { tagInfo ->
+                outputSet.removeField(tagInfo)
+            }
         } ?: return@withContext jpegImageFile
 
         val scrubbedFile = temporaryFileCreator.create()
@@ -82,4 +71,12 @@ internal class ImageExifTagRemover @Inject constructor(
                 }
         )
     }
+
+    private val tagsToRemove
+        get() = GpsTagConstants.ALL_GPS_TAGS +
+                listOf(
+                        ExifTagConstants.EXIF_TAG_GPSINFO,
+                        ExifTagConstants.EXIF_TAG_SUBJECT_LOCATION,
+                        ExifTagConstants.EXIF_TAG_USER_COMMENT,
+                )
 }
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 676a4f6a38cfcd9f45b546ca839d0b1fdf5845ec..e0919c52e3e9c49aff4f0a962d2aa2a69ae76c98 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
@@ -28,7 +28,7 @@ internal object FilterFactory {
                 limit = numberOfEvents,
 //                senders = listOf(userId),
 //                relationSenders = userId?.let { listOf(it) },
-                relationTypes = listOf(RelationType.THREAD)
+                relationTypes = listOf(RelationType.THREAD),
         )
     }
 
@@ -37,7 +37,7 @@ internal object FilterFactory {
                 limit = numberOfEvents,
                 containsUrl = true,
                 types = listOf(EventType.MESSAGE),
-                lazyLoadMembers = true
+                lazyLoadMembers = true,
         )
     }
 
@@ -55,30 +55,25 @@ internal object FilterFactory {
     }
 
     fun createDefaultRoomFilter(): RoomEventFilter {
-        return RoomEventFilter(
-                lazyLoadMembers = true
-        )
+        return RoomEventFilter(lazyLoadMembers = true)
     }
 
     fun createElementRoomFilter(): RoomEventFilter {
         return RoomEventFilter(
-                lazyLoadMembers = true
+                lazyLoadMembers = true,
                 // TODO Enable this for optimization
                 // types = (listOfSupportedEventTypes + listOfSupportedStateEventTypes).toMutableList()
         )
     }
 
     private fun createElementTimelineFilter(): RoomEventFilter? {
-        return null // RoomEventFilter().apply {
-        // TODO Enable this for optimization
-        // types = listOfSupportedEventTypes.toMutableList()
-        // }
+//        we need to check if homeserver supports thread notifications before setting this param
+//        return RoomEventFilter(enableUnreadThreadNotifications = true)
+        return null
     }
 
     private fun createElementStateFilter(): RoomEventFilter {
-        return RoomEventFilter(
-                lazyLoadMembers = true
-        )
+        return RoomEventFilter(lazyLoadMembers = true)
     }
 
     // Get only managed types by Element
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 220c401137521778d83810d2744968400210915c..4bb66a6159f21fd94303af31fe7cfb88c62ef4e8 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
@@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.filter
 
 import com.squareup.moshi.Json
 import com.squareup.moshi.JsonClass
+import org.matrix.android.sdk.api.session.sync.model.RoomSync
 import org.matrix.android.sdk.internal.di.MoshiProvider
 
 /**
@@ -74,9 +75,15 @@ internal data class RoomEventFilter(
          */
         @Json(name = "contains_url") val containsUrl: Boolean? = null,
         /**
-         * If true, enables lazy-loading of membership events. See Lazy-loading room members for more information. Defaults to false.
+         * If true, enables lazy-loading of membership events.
+         * See Lazy-loading room members for more information.
+         * Defaults to false.
          */
-        @Json(name = "lazy_load_members") val lazyLoadMembers: Boolean? = null
+        @Json(name = "lazy_load_members") val lazyLoadMembers: Boolean? = null,
+        /**
+         * If true, this will opt-in for the server to return unread threads notifications in [RoomSync].
+         */
+        @Json(name = "unread_thread_notifications") val enableUnreadThreadNotifications: Boolean? = null,
 ) {
 
     fun toJSONString(): String {
@@ -92,6 +99,7 @@ internal data class RoomEventFilter(
                 rooms != null ||
                 notRooms != null ||
                 containsUrl != null ||
-                lazyLoadMembers != null)
+                lazyLoadMembers != null ||
+                enableUnreadThreadNotifications != null)
     }
 }
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 2c3cb440b62a984d8e993e3c6938852bb94c7222..a5953d870c80366a82f0d1c89bd710d43f4fee96 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
@@ -25,6 +25,7 @@ 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.doesServerSupportLogoutDevices
 import org.matrix.android.sdk.internal.auth.version.doesServerSupportQrCodeLogin
+import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreadUnreadNotifications
 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
@@ -144,6 +145,8 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
                 homeServerCapabilitiesEntity.canControlLogoutDevices = getVersionResult.doesServerSupportLogoutDevices()
                 homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */
                         getVersionResult.doesServerSupportThreads()
+                homeServerCapabilitiesEntity.canUseThreadReadReceiptsAndNotifications =
+                        getVersionResult.doesServerSupportThreadUnreadNotifications()
                 homeServerCapabilitiesEntity.canLoginWithQrCode = getVersionResult.doesServerSupportQrCodeLogin()
             }
 
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 6979d4282740495931923df4073f03ca3439f696..7c83a4afa79c2f81742e5a980eacaf061b3666bc 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
@@ -39,6 +39,7 @@ import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
 import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.api.session.sync.model.RoomSyncSummary
 import org.matrix.android.sdk.api.session.sync.model.RoomSyncUnreadNotifications
+import org.matrix.android.sdk.api.session.sync.model.RoomSyncUnreadThreadNotifications
 import org.matrix.android.sdk.internal.crypto.EventDecryptor
 import org.matrix.android.sdk.internal.crypto.crosssigning.DefaultCrossSigningService
 import org.matrix.android.sdk.internal.database.mapper.ContentMapper
@@ -91,6 +92,7 @@ internal class RoomSummaryUpdater @Inject constructor(
             membership: Membership? = null,
             roomSummary: RoomSyncSummary? = null,
             unreadNotifications: RoomSyncUnreadNotifications? = null,
+            unreadThreadNotifications: Map<String, RoomSyncUnreadThreadNotifications>? = null,
             updateMembers: Boolean = false,
             inviterId: String? = null,
             aggregator: SyncResponsePostTreatmentAggregator? = null
@@ -111,6 +113,14 @@ internal class RoomSummaryUpdater @Inject constructor(
         roomSummaryEntity.highlightCount = unreadNotifications?.highlightCount ?: 0
         roomSummaryEntity.notificationCount = unreadNotifications?.notificationCount ?: 0
 
+        roomSummaryEntity.threadHighlightCount = unreadThreadNotifications
+                ?.count { (it.value.highlightCount ?: 0) > 0 }
+                ?: 0
+
+        roomSummaryEntity.threadNotificationCount = unreadThreadNotifications
+                ?.count { (it.value.notificationCount ?: 0) > 0 }
+                ?: 0
+
         if (membership != null) {
             roomSummaryEntity.membership = membership
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt
index 20094e4be8a354b483740bc37176381739bca62e..2d6082f9b56905386173333648354752a0d18542 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt
@@ -22,6 +22,8 @@ import io.realm.Sort
 import org.matrix.android.sdk.api.session.events.model.getRelationContent
 import org.matrix.android.sdk.api.session.events.model.isImageMessage
 import org.matrix.android.sdk.api.session.events.model.isVideoMessage
+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.timeline.TimelineEvent
 import org.matrix.android.sdk.api.util.Optional
 import org.matrix.android.sdk.internal.database.RealmSessionProvider
@@ -74,7 +76,13 @@ internal class TimelineEventDataSource @Inject constructor(
                     .distinct(TimelineEventEntityFields.EVENT_ID)
                     .findAll()
                     .mapNotNull {
-                        timelineEventMapper.map(it).takeIf { it.root.getRelationContent()?.takeIf { it.type == eventType && it.eventId == eventId } != null }
+                        timelineEventMapper.map(it)
+                                .takeIf {
+                                    val isEventRelatedTo = it.root.getRelationContent()?.takeIf { it.type == eventType && it.eventId == eventId } != null
+                                    val isContentRelatedTo = it.root.getClearContent()?.toModel<MessageContent>()
+                                            ?.relatesTo?.takeIf { it.type == eventType && it.eventId == eventId } != null
+                                    isEventRelatedTo || isContentRelatedTo
+                                }
                     }
         }
     }
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 ea296d379ddc51feb783eb8f1f3e14b98b3218a7..bc1a69769d6ce3b2d3e579fa450883217088d7cf 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
@@ -140,7 +140,7 @@ internal class DefaultSyncTask @Inject constructor(
                         executeRequest(globalErrorReceiver) {
                             syncAPI.sync(
                                     params = requestParams,
-                                    readTimeOut = readTimeOut
+                                    readTimeOut = readTimeOut,
                             )
                         }
                     }
@@ -178,7 +178,7 @@ internal class DefaultSyncTask @Inject constructor(
             syncRequestStateTracker.setSyncRequestState(
                     SyncRequestState.IncrementalSyncParsing(
                             rooms = nbRooms,
-                            toDevice = nbToDevice
+                            toDevice = nbToDevice,
                     )
             )
             syncResponseHandler.handleResponse(syncResponse, token, null)
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 a2f2251b70e032c6f768cced51d275335a5932d1..2825be8291d48491b79c3f1bedb8fb5f9f06277f 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
@@ -287,6 +287,7 @@ internal class RoomSyncHandler @Inject constructor(
                 Membership.JOIN,
                 roomSync.summary,
                 roomSync.unreadNotifications,
+                roomSync.unreadThreadNotifications,
                 updateMembers = hasRoomMember,
                 aggregator = aggregator
         )
@@ -372,7 +373,8 @@ internal class RoomSyncHandler @Inject constructor(
         roomEntity.chunks.clearWith { it.deleteOnCascade(deleteStateEvents = true, canDeleteRoot = true) }
         roomTypingUsersHandler.handle(realm, roomId, null)
         roomChangeMembershipStateDataSource.setMembershipFromSync(roomId, Membership.LEAVE)
-        roomSummaryUpdater.update(realm, roomId, membership, roomSync.summary, roomSync.unreadNotifications, aggregator = aggregator)
+        roomSummaryUpdater.update(realm, roomId, membership, roomSync.summary,
+                roomSync.unreadNotifications, roomSync.unreadThreadNotifications, aggregator = aggregator)
         return roomEntity
     }