diff --git a/CHANGES.md b/CHANGES.md
index aa2ab532b4f7e7e2024c20991192df88dabcb6bb..b6fc44b4028513d1bd4bb0323dfdfd9380ddcb7c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,27 @@
 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.4 (2022-10-25)
+=======================================
+
+Imported from Element 1.5.4. (https://github.com/vector-im/element-android/releases/tag/v1.5.4)
+
+Target API 33.
+
+SDK API changes ⚠️
+------------------
+- Stop using `original_event` field from `/relations` endpoint ([#7282](https://github.com/vector-im/element-android/issues/7282))
+- Add `formattedText` or similar optional parameters in several methods:
+* RelationService:
+    * editTextMessage
+    * editReply
+    * replyToMessage
+* SendService:
+    * sendQuotedTextMessage
+      This allows us to send any HTML formatted text message without needing to rely on automatic Markdown > HTML translation. All these new parameters have a `null` value by default, so previous calls to these API methods remain compatible. ([#7288](https://github.com/vector-im/element-android/issues/7288))
+- Add support for `m.login.token` auth during QR code based sign in ([#7358](https://github.com/vector-im/element-android/issues/7358))
+- Allow getting the formatted or plain text body of a message for the fun `TimelineEvent.getTextEditableContent()`. ([#7359](https://github.com/vector-im/element-android/issues/7359))
+
+
 Changes in Matrix-SDK v1.5.2 (2022-10-05)
 =======================================
 
diff --git a/dependencies.gradle b/dependencies.gradle
index 3bf3ab746d8753ec420b11dd0cd6cd741679f9d4..f081e0a87439a6b2b33a9ba8c020f310fc198b3a 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -1,35 +1,34 @@
 ext.versions = [
 
         'minSdk'            : 21,
-        'compileSdk'        : 32,
-        'targetSdk'         : 32,
+        'compileSdk'        : 33,
+        'targetSdk'         : 33,
         'sourceCompat'      : JavaVersion.VERSION_11,
         'targetCompat'      : JavaVersion.VERSION_11,
 ]
 
-def gradle = "7.2.2"
+def gradle = "7.3.1"
 // Ref: https://kotlinlang.org/releases.html
 def kotlin = "1.7.20"
 def kotlinCoroutines = "1.6.4"
 def dagger = "2.44"
 def appDistribution = "16.0.0-beta04"
 def retrofit = "2.9.0"
-def arrow = "0.8.2"
 def markwon = "4.6.2"
 def moshi = "1.14.0"
 def lifecycle = "2.5.1"
 def flowBinding = "1.2.0"
-def flipper = "0.164.0"
-def epoxy = "4.6.2"
-def mavericks = "2.7.0"
-def glide = "4.14.1"
+def flipper = "0.171.1"
+def epoxy = "5.0.0"
+def mavericks = "3.0.1"
+def glide = "4.14.2"
 def bigImageViewer = "1.8.1"
 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.1"
+def sentry = "6.4.3"
 
 def fragment = "1.5.3"
 
@@ -51,12 +50,12 @@ ext.libs = [
                 'coroutinesTest'          : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutines"
         ],
         androidx    : [
-                'activity'                : "androidx.activity:activity:1.5.1",
+                'activity'                : "androidx.activity:activity-ktx:1.6.0",
                 'appCompat'               : "androidx.appcompat:appcompat:1.5.1",
                 'biometric'               : "androidx.biometric:biometric:1.1.0",
-                'core'                    : "androidx.core:core-ktx:1.8.0",
+                'core'                    : "androidx.core:core-ktx:1.9.0",
                 'recyclerview'            : "androidx.recyclerview:recyclerview:1.2.1",
-                'exifinterface'           : "androidx.exifinterface:exifinterface:1.3.3",
+                'exifinterface'           : "androidx.exifinterface:exifinterface:1.3.4",
                 'fragmentKtx'             : "androidx.fragment:fragment-ktx:$fragment",
                 'fragmentTesting'         : "androidx.fragment:fragment-testing:$fragment",
                 'constraintLayout'        : "androidx.constraintlayout:constraintlayout:2.1.4",
@@ -87,7 +86,7 @@ ext.libs = [
                 'appdistributionApi'      : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution",
                 'appdistribution'         : "com.google.firebase:firebase-appdistribution:$appDistribution",
                 // Phone number https://github.com/google/libphonenumber
-                'phonenumber'             : "com.googlecode.libphonenumber:libphonenumber:8.12.56"
+                'phonenumber'             : "com.googlecode.libphonenumber:libphonenumber:8.12.57"
         ],
         dagger      : [
                 'dagger'                  : "com.google.dagger:dagger:$dagger",
@@ -102,6 +101,7 @@ ext.libs = [
         ],
         element     : [
                 'opusencoder'             : "io.element.android:opusencoder:1.1.0",
+                'wysiwyg'                 : "io.element.android:wysiwyg:0.2.1"
         ],
         squareup    : [
                 'moshi'                  : "com.squareup.moshi:moshi:$moshi",
@@ -114,10 +114,6 @@ ext.libs = [
         rx          : [
                 'rxKotlin'               : "io.reactivex.rxjava2:rxkotlin:2.4.0"
         ],
-        arrow       : [
-                'core'                   : "io.arrow-kt:arrow-core:$arrow",
-                'instances'              : "io.arrow-kt:arrow-instances-core:$arrow"
-        ],
         markwon     : [
                 'core'                   : "io.noties.markwon:core:$markwon",
                 'extLatex'               : "io.noties.markwon:ext-latex:$markwon",
diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle
index cdab6172d1068cf9c58980673baae54fadab9b9a..68de2c1581272ad0776c34f7da70acf83e99e804 100644
--- a/dependencies_groups.gradle
+++ b/dependencies_groups.gradle
@@ -84,6 +84,7 @@ ext.groups = [
                         'com.google',
                         'com.google.android',
                         'com.google.api.grpc',
+                        'com.google.auto',
                         'com.google.auto.service',
                         'com.google.auto.value',
                         'com.google.code.findbugs',
@@ -101,6 +102,7 @@ ext.groups = [
                         'com.googlecode.json-simple',
                         'com.googlecode.libphonenumber',
                         'com.ibm.icu',
+                        'com.intellij',
                         'com.jakewharton.android.repackaged',
                         'com.jakewharton.timber',
                         'com.kgurgul.flipper',
@@ -132,7 +134,6 @@ ext.groups = [
                         'commons-io',
                         'commons-logging',
                         'info.picocli',
-                        'io.arrow-kt',
                         'io.element.android',
                         'io.github.davidburstrom.contester',
                         'io.github.detekt.sarif4k',
@@ -146,6 +147,7 @@ ext.groups = [
                         'io.netty',
                         'io.noties.markwon',
                         'io.opencensus',
+                        'io.perfmark',
                         'io.reactivex.rxjava2',
                         'io.realm',
                         'io.sentry',
@@ -176,6 +178,7 @@ ext.groups = [
                         'org.apache.httpcomponents',
                         'org.apache.sanselan',
                         'org.bouncycastle',
+                        'org.ccil.cowan.tagsoup',
                         'org.checkerframework',
                         'org.codehaus',
                         'org.codehaus.groovy',
diff --git a/gradle.properties b/gradle.properties
index f443623387d48b642dd9492286a6d64583086465..bc760114f3338ee003085f33b5006315ef45693f 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.2
+VERSION_NAME=1.5.4
 
 POM_PACKAGING=aar
 
diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle
index 9836e1c8bb285132b6c9b3e298fbba3afef654a5..99a8a2c914bc87713eb625e71c76638186e21957 100644
--- a/matrix-sdk-android/build.gradle
+++ b/matrix-sdk-android/build.gradle
@@ -45,6 +45,8 @@ dokkaHtml {
 }
 
 android {
+    namespace "org.matrix.android.sdk"
+
     testOptions.unitTests.includeAndroidResources = true
 
     compileSdk versions.compileSdk
diff --git a/matrix-sdk-android/src/main/AndroidManifest.xml b/matrix-sdk-android/src/main/AndroidManifest.xml
index de0731422c11426399db25c4e274d733ba8096e5..7f940d4e1c7c2d4e430140318f0f8d639814401f 100644
--- a/matrix-sdk-android/src/main/AndroidManifest.xml
+++ b/matrix-sdk-android/src/main/AndroidManifest.xml
@@ -1,6 +1,5 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    package="org.matrix.android.sdk">
+    xmlns:tools="http://schemas.android.com/tools">
 
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/account/LocalNotificationSettingsContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/account/LocalNotificationSettingsContent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2a95ccce7a743114ad4b81517e6955c479c0a303
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/account/LocalNotificationSettingsContent.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.account
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class LocalNotificationSettingsContent(
+        @Json(name = "is_silenced") val isSilenced: Boolean = false
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
index 5ae70e1978c0e5bc9398560a12b81123b6a369ca..252c33a8c4cc8957ee23be81005896c93a76a8e8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
@@ -124,4 +124,24 @@ interface AuthenticationService {
             initialDeviceName: String,
             deviceId: String? = null
     ): Session
+
+    /**
+     * @param homeServerConnectionConfig the information about the homeserver and other configuration
+     * Return true if qr code login is supported by the server, false otherwise.
+     */
+    suspend fun isQrLoginSupported(homeServerConnectionConfig: HomeServerConnectionConfig): Boolean
+
+    /**
+     * Authenticate using m.login.token method during sign in with QR code.
+     * @param homeServerConnectionConfig the information about the homeserver and other configuration
+     * @param loginToken the m.login.token
+     * @param initialDeviceName the initial device name
+     * @param deviceId the device id, optional. If not provided or null, the server will generate one.
+     */
+    suspend fun loginUsingQrLoginToken(
+            homeServerConnectionConfig: HomeServerConnectionConfig,
+            loginToken: String,
+            initialDeviceName: String? = null,
+            deviceId: String? = null
+    ): Session
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/LoginType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/LoginType.kt
index 627a8256799d455b80a8afa75b9bc3485b334289..991b7b654d5e2e0f443acc5e14d6bdc949c11f66 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/LoginType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/LoginType.kt
@@ -22,7 +22,8 @@ enum class LoginType {
     UNSUPPORTED,
     CUSTOM,
     DIRECT,
-    UNKNOWN;
+    UNKNOWN,
+    QR;
 
     companion object {
 
@@ -32,6 +33,7 @@ enum class LoginType {
             UNSUPPORTED.name -> UNSUPPORTED
             CUSTOM.name -> CUSTOM
             DIRECT.name -> DIRECT
+            QR.name -> QR
             else -> UNKNOWN
         }
     }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt
index 91167d896f0251e3d6bbafb6181dcd7a45a11fa6..0edd824c26818b585c129117a2488eab435cce5e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt
@@ -28,4 +28,5 @@ object UserAccountDataTypes {
     const val TYPE_IDENTITY_SERVER = "m.identity_server"
     const val TYPE_ACCEPTED_TERMS = "m.accepted_terms"
     const val TYPE_OVERRIDE_COLORS = "im.vector.setting.override_colors"
+    const val TYPE_LOCAL_NOTIFICATION_SETTINGS = "org.matrix.msc3890.local_notification_settings."
 }
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 f5d2c0d9a0ef6592e932fd34c5f6ba2dd321d76b..71daf4cc4f2fada741fe69d3882b1630b918f53b 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
@@ -31,6 +31,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent
 import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageType
+import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
 import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
 import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
 import org.matrix.android.sdk.api.session.room.send.SendState
@@ -357,6 +358,10 @@ fun Event.isAudioMessage(): Boolean {
     }
 }
 
+fun Event.isVoiceMessage(): Boolean {
+    return this.asMessageAudioEvent()?.content?.voiceMessageIndicator != null
+}
+
 fun Event.isFileMessage(): Boolean {
     return when (getMsgType()) {
         MessageType.MSGTYPE_FILE -> true
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 b5d6d891e400a3f18e132c85dfbc9683efd8c6bb..8c14ca892aae1366229f69bc75f845664e8df44e 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
@@ -59,7 +59,12 @@ data class HomeServerCapabilities(
         /**
          * True if the home server supports controlling the logout of all devices when changing password.
          */
-        val canControlLogoutDevices: Boolean = false
+        val canControlLogoutDevices: Boolean = false,
+
+        /**
+         * True if the home server supports login via qr code, false otherwise.
+         */
+        val canLoginWithQrCode: Boolean = false,
 ) {
 
     enum class RoomCapabilitySupport {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt
index 1ae23e2b7015c7dd58163ab0dad7a67fc489df37..1258c5c02f77faa338c4b4203e70e7fec60fd2fa 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt
@@ -58,6 +58,16 @@ data class HttpPusher(
          */
         val url: String,
 
+        /**
+         * Whether the pusher should actively create push notifications.
+         */
+        val enabled: Boolean,
+
+        /**
+         * The device ID of the session that registered the pusher.
+         */
+        val deviceId: String,
+
         /**
          * If true, the homeserver should add another pusher with the given pushkey and App ID in addition
          * to any others with different user IDs. Otherwise, the homeserver must remove any other pushers
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt
index b85ab32b2107f1e3fef0b7d4c510de3b2b156391..92ac6c483b89f9168a8ea19833d4c638990b907d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt
@@ -24,8 +24,9 @@ data class Pusher(
         val profileTag: String? = null,
         val lang: String?,
         val data: PusherData,
-
-        val state: PusherState
+        val enabled: Boolean,
+        val deviceId: String?,
+        val state: PusherState,
 ) {
     companion object {
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt
index d7958ea3cdd0f6e44c1ab00577687fb3efa836ed..6a27f7af613059bc95f223b5b076e32adc3e0c74 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt
@@ -67,6 +67,14 @@ interface PushersService {
             append: Boolean = true
     )
 
+    /**
+     * Enables or disables a registered pusher.
+     *
+     * @param pusher The pusher being toggled
+     * @param enable Whether the pusher should be enabled or disabled
+     */
+    suspend fun togglePusher(pusher: Pusher, enable: Boolean)
+
     /**
      * Directly ask the push gateway to send a push to this device.
      * If successful, the push gateway has accepted the request. In this case, the app should receive a Push with the provided eventId.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageAudioEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageAudioEvent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..38ced8f385fd6c5a1effed89f61e84dcb21121ca
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageAudioEvent.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.session.room.model.message
+
+import org.matrix.android.sdk.api.extensions.tryOrNull
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.events.model.toModel
+
+/**
+ * [Event] wrapper for [EventType.MESSAGE] event type.
+ * Provides additional fields and functions related to this event type.
+ */
+@JvmInline
+value class MessageAudioEvent(val root: Event) {
+
+    /**
+     * The mapped [MessageAudioContent] model of the event content.
+     */
+    val content: MessageAudioContent
+        get() = root.getClearContent().toModel<MessageContent>() as MessageAudioContent
+
+    init {
+        require(tryOrNull { content } != null)
+    }
+}
+
+/**
+ * Map a [EventType.MESSAGE] event to a [MessageAudioEvent].
+ */
+fun Event.asMessageAudioEvent() = if (getClearType() == EventType.MESSAGE) {
+    tryOrNull { MessageAudioEvent(this) }
+} else null
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt
index b12d9ed6c825145c7d029e816be8ba4bdc8ce999..e97a5be3037d2f8978633fcc43a1befd4b181869 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt
@@ -43,4 +43,7 @@ object MessageType {
     // Fake message types for live location events to be able to inherit them from MessageContent
     const val MSGTYPE_BEACON_INFO = "org.matrix.android.sdk.beacon.info"
     const val MSGTYPE_BEACON_LOCATION_DATA = "org.matrix.android.sdk.beacon.location.data"
+
+    // Fake message types for voice broadcast events to be able to inherit them from MessageContent
+    const val MSGTYPE_VOICE_BROADCAST_INFO = "io.element.voicebroadcast.info"
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt
index d34ea3c7d3658780e9b176d622957c9e7f2c1950..e7fcabf3867e7ec4e7b0c536544fa708caad5e68 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt
@@ -91,7 +91,8 @@ interface RelationService {
      * Edit a text message body. Limited to "m.text" contentType.
      * @param targetEvent The event to edit
      * @param msgType the message type
-     * @param newBodyText The edited body
+     * @param newBodyText The edited body in plain text
+     * @param newFormattedBodyText The edited body with format
      * @param newBodyAutoMarkdown true to parse markdown on the new body
      * @param compatibilityBodyText The text that will appear on clients that don't support yet edition
      */
@@ -99,6 +100,7 @@ interface RelationService {
             targetEvent: TimelineEvent,
             msgType: String,
             newBodyText: CharSequence,
+            newFormattedBodyText: CharSequence? = null,
             newBodyAutoMarkdown: Boolean,
             compatibilityBodyText: String = "* $newBodyText"
     ): Cancelable
@@ -108,13 +110,15 @@ interface RelationService {
      * This method will take the new body (stripped from fallbacks) and re-add them before sending.
      * @param replyToEdit The event to edit
      * @param originalTimelineEvent the message that this reply (being edited) is relating to
-     * @param newBodyText The edited body (stripped from in reply to content)
+     * @param newBodyText The plain text edited body (stripped from in reply to content)
+     * @param newFormattedBodyText The formatted edited body (stripped from in reply to content)
      * @param compatibilityBodyText The text that will appear on clients that don't support yet edition
      */
     fun editReply(
             replyToEdit: TimelineEvent,
             originalTimelineEvent: TimelineEvent,
             newBodyText: String,
+            newFormattedBodyText: String? = null,
             compatibilityBodyText: String = "* $newBodyText"
     ): Cancelable
 
@@ -133,6 +137,7 @@ interface RelationService {
      * by the sdk into pills.
      * @param eventReplied the event referenced by the reply
      * @param replyText the reply text
+     * @param replyFormattedText the reply text, formatted
      * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
      * @param showInThread If true, relation will be added to the reply in order to be visible from within threads
      * @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation
@@ -140,6 +145,7 @@ interface RelationService {
     fun replyToMessage(
             eventReplied: TimelineEvent,
             replyText: CharSequence,
+            replyFormattedText: CharSequence? = null,
             autoMarkdown: Boolean = false,
             showInThread: Boolean = false,
             rootThreadEventId: String? = null
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
index 9cf062356f3e132159307746e4681d69d23cbf68..6a6fadc95a7d3e0f61b98e9d44a04f06113e23a5 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
@@ -21,6 +21,7 @@ import org.matrix.android.sdk.api.session.events.model.Content
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.room.model.message.MessageType
 import org.matrix.android.sdk.api.session.room.model.message.PollType
+import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 import org.matrix.android.sdk.api.util.Cancelable
 
@@ -44,28 +45,49 @@ interface SendService {
      * @param text the text message to send
      * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
      * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
-    fun sendTextMessage(text: CharSequence, msgType: String = MessageType.MSGTYPE_TEXT, autoMarkdown: Boolean = false): Cancelable
+    fun sendTextMessage(
+            text: CharSequence,
+            msgType: String = MessageType.MSGTYPE_TEXT,
+            autoMarkdown: Boolean = false,
+            additionalContent: Content? = null,
+    ): Cancelable
 
     /**
      * Method to send a text message with a formatted body.
      * @param text the text message to send
      * @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML
      * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
-    fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String = MessageType.MSGTYPE_TEXT): Cancelable
+    fun sendFormattedTextMessage(
+            text: String,
+            formattedText: String,
+            msgType: String = MessageType.MSGTYPE_TEXT,
+            additionalContent: Content? = null,
+    ): Cancelable
 
     /**
      * Method to quote an events content.
      * @param quotedEvent The event to which we will quote it's content.
-     * @param text the text message to send
+     * @param text the plain text message to send
+     * @param formattedText the formatted text message to send
      * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
      * @param rootThreadEventId when this param is not null, the message will be sent in this specific thread
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
-    fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable
+    fun sendQuotedTextMessage(
+            quotedEvent: TimelineEvent,
+            text: String,
+            formattedText: String? = null,
+            autoMarkdown: Boolean,
+            rootThreadEventId: String? = null,
+            additionalContent: Content? = null,
+    ): Cancelable
 
     /**
      * Method to send a media asynchronously.
@@ -74,13 +96,17 @@ interface SendService {
      * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
      *                It can be useful to send media to multiple room. It's safe to include the current roomId in this set
      * @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread
+     * @param relatesTo add a relation content to the media event
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
     fun sendMedia(
             attachment: ContentAttachmentData,
             compressBeforeSending: Boolean,
             roomIds: Set<String>,
-            rootThreadEventId: String? = null
+            rootThreadEventId: String? = null,
+            relatesTo: RelationDefaultContent? = null,
+            additionalContent: Content? = null,
     ): Cancelable
 
     /**
@@ -90,13 +116,15 @@ interface SendService {
      * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
      *                It can be useful to send media to multiple room. It's safe to include the current roomId in this set
      * @param rootThreadEventId when this param is not null, all the Media will be sent in this specific thread
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
     fun sendMedias(
             attachments: List<ContentAttachmentData>,
             compressBeforeSending: Boolean,
             roomIds: Set<String>,
-            rootThreadEventId: String? = null
+            rootThreadEventId: String? = null,
+            additionalContent: Content? = null,
     ): Cancelable
 
     /**
@@ -104,31 +132,35 @@ interface SendService {
      * @param pollType indicates open or closed polls
      * @param question the question
      * @param options list of options
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
-    fun sendPoll(pollType: PollType, question: String, options: List<String>): Cancelable
+    fun sendPoll(pollType: PollType, question: String, options: List<String>, additionalContent: Content? = null): Cancelable
 
     /**
      * Method to send a poll response.
      * @param pollEventId the poll currently replied to
      * @param answerId The id of the answer
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
-    fun voteToPoll(pollEventId: String, answerId: String): Cancelable
+    fun voteToPoll(pollEventId: String, answerId: String, additionalContent: Content? = null): Cancelable
 
     /**
      * End a poll in the room.
      * @param pollEventId event id of the poll
+     * @param additionalContent additional content to put in the event content
      * @return a [Cancelable]
      */
-    fun endPoll(pollEventId: String): Cancelable
+    fun endPoll(pollEventId: String, additionalContent: Content? = null): Cancelable
 
     /**
      * Redact (delete) the given event.
      * @param event The event to redact
      * @param reason Optional reason string
+     * @param additionalContent additional content to put in the event content
      */
-    fun redactEvent(event: Event, reason: String?): Cancelable
+    fun redactEvent(event: Event, reason: String?, additionalContent: Content? = null): Cancelable
 
     /**
      * Schedule this message to be resent.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
index d391abf1e61528cc3768335b0d36aab8a550489b..223acd1b9c6bff98e50ba829053d3a39ed7ffe69 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
@@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.model.ReadReceipt
 import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageContent
+import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
 import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@@ -180,8 +181,13 @@ fun TimelineEvent.isRootThread(): Boolean {
 /**
  * Get the latest message body, after a possible edition, stripping the reply prefix if necessary.
  */
-fun TimelineEvent.getTextEditableContent(): String {
-    val lastContentBody = getLastMessageContent()?.body ?: return ""
+fun TimelineEvent.getTextEditableContent(formatted: Boolean): String {
+    val lastMessageContent = getLastMessageContent()
+    val lastContentBody = if (formatted && lastMessageContent is MessageContentWithFormattedBody) {
+        lastMessageContent.formattedBody
+    } else {
+        lastMessageContent?.body
+    } ?: return ""
     return if (isReply()) {
         extractUsefulTextFromReply(lastContentBody)
     } else {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
index 46433f387d36b2297d3571c687feadb122a9e2c0..aa9afd5c8c141e1867ba80c21b1090680dd684fe 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
@@ -55,4 +55,9 @@ interface TimelineService {
      * Returns a snapshot list of TimelineEvent with EventType.MESSAGE and MessageType.MSGTYPE_IMAGE or MessageType.MSGTYPE_VIDEO.
      */
     fun getAttachmentMessages(): List<TimelineEvent>
+
+    /**
+     * Returns a snapshot list of TimelineEvent with a content relation of the given type to the given eventId.
+     */
+    fun getTimelineEventsRelatedTo(relationType: String, eventId: String): List<TimelineEvent>
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/Compat.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/Compat.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b685c4971371ec594c1b306ffa003a7da591a346
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/Compat.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.util
+
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Build
+
+fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo {
+    return when {
+        Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApplicationInfo(
+                packageName,
+                PackageManager.ApplicationInfoFlags.of(flags.toLong())
+        )
+        else -> @Suppress("DEPRECATION") getApplicationInfo(packageName, flags)
+    }
+}
+
+fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int): PackageInfo {
+    return when {
+        Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getPackageInfo(
+                packageName,
+                PackageManager.PackageInfoFlags.of(flags.toLong())
+        )
+        else -> @Suppress("DEPRECATION") getPackageInfo(packageName, flags)
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/Optional.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/Optional.kt
index 42724746c016d229b450efa490cd25b86376c4eb..2da6d4cbf8659bf7ddcf0820a874ac7f97bd4f63 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/Optional.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/Optional.kt
@@ -15,15 +15,13 @@
  */
 package org.matrix.android.sdk.api.util
 
-data class Optional<T : Any> constructor(private val value: T?) {
+data class Optional<T : Any>(private val value: T?) {
 
-    fun get(): T {
-        return value!!
-    }
+    fun get(): T = value!!
 
-    fun getOrNull(): T? {
-        return value
-    }
+    fun orNull(): T? = value
+
+    fun getOrNull(): T? = value
 
     fun <U : Any> map(fn: (T) -> U?): Optional<U> {
         return if (value == null) {
@@ -33,23 +31,19 @@ data class Optional<T : Any> constructor(private val value: T?) {
         }
     }
 
-    fun getOrElse(fn: () -> T): T {
+    fun orElse(fn: () -> T): T {
         return value ?: fn()
     }
 
-    fun hasValue(): Boolean {
-        return value != null
-    }
+    fun hasValue(): Boolean = value != null
 
     companion object {
-        fun <T : Any> from(value: T?): Optional<T> {
-            return Optional(value)
-        }
+        fun <T : Any> from(value: T?): Optional<T> = Optional(value)
 
-        fun <T : Any> empty(): Optional<T> {
-            return Optional(null)
-        }
+        fun <T : Any> empty(): Optional<T> = Optional(null)
     }
 }
 
+fun <T : Any> T?.toOption() = Optional(this)
+
 fun <T : Any> T?.toOptional() = Optional(this)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt
index 463692e574cf767a9837150a643c9ffa9094e4fb..b1f65194f1e16250531073ed9de81eda7dbd725f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt
@@ -29,7 +29,9 @@ import org.matrix.android.sdk.internal.auth.db.AuthRealmModule
 import org.matrix.android.sdk.internal.auth.db.RealmPendingSessionStore
 import org.matrix.android.sdk.internal.auth.db.RealmSessionParamsStore
 import org.matrix.android.sdk.internal.auth.login.DefaultDirectLoginTask
+import org.matrix.android.sdk.internal.auth.login.DefaultQrLoginTokenTask
 import org.matrix.android.sdk.internal.auth.login.DirectLoginTask
+import org.matrix.android.sdk.internal.auth.login.QrLoginTokenTask
 import org.matrix.android.sdk.internal.database.RealmKeysUtils
 import org.matrix.android.sdk.internal.di.AuthDatabase
 import org.matrix.android.sdk.internal.legacy.DefaultLegacySessionImporter
@@ -94,4 +96,7 @@ internal abstract class AuthModule {
 
     @Binds
     abstract fun bindHomeServerHistoryService(service: DefaultHomeServerHistoryService): HomeServerHistoryService
+
+    @Binds
+    abstract fun bindQrLoginTokenTask(task: DefaultQrLoginTokenTask): QrLoginTokenTask
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
index 446f9318479fc16d0898f84d23b6c034b68645e3..5449c0a735ff068dc796fbab1d9bbce5e860ddbe 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
@@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
 import org.matrix.android.sdk.api.auth.login.LoginWizard
 import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
 import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
+import org.matrix.android.sdk.api.extensions.orFalse
 import org.matrix.android.sdk.api.failure.Failure
 import org.matrix.android.sdk.api.failure.MatrixIdFailure
 import org.matrix.android.sdk.api.session.Session
@@ -39,9 +40,11 @@ import org.matrix.android.sdk.internal.auth.data.WebClientConfig
 import org.matrix.android.sdk.internal.auth.db.PendingSessionData
 import org.matrix.android.sdk.internal.auth.login.DefaultLoginWizard
 import org.matrix.android.sdk.internal.auth.login.DirectLoginTask
+import org.matrix.android.sdk.internal.auth.login.QrLoginTokenTask
 import org.matrix.android.sdk.internal.auth.registration.DefaultRegistrationWizard
 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.isLoginAndRegistrationSupportedBySdk
 import org.matrix.android.sdk.internal.auth.version.isSupportedBySdk
 import org.matrix.android.sdk.internal.di.Unauthenticated
@@ -62,7 +65,8 @@ internal class DefaultAuthenticationService @Inject constructor(
         private val sessionCreator: SessionCreator,
         private val pendingSessionStore: PendingSessionStore,
         private val getWellknownTask: GetWellknownTask,
-        private val directLoginTask: DirectLoginTask
+        private val directLoginTask: DirectLoginTask,
+        private val qrLoginTokenTask: QrLoginTokenTask
 ) : AuthenticationService {
 
     private var pendingSessionData: PendingSessionData? = pendingSessionStore.getPendingSessionData()
@@ -404,6 +408,36 @@ internal class DefaultAuthenticationService @Inject constructor(
         )
     }
 
+    override suspend fun isQrLoginSupported(homeServerConnectionConfig: HomeServerConnectionConfig): Boolean {
+        val authAPI = buildAuthAPI(homeServerConnectionConfig)
+        val versions = runCatching {
+            executeRequest(null) {
+                authAPI.versions()
+            }
+        }
+        return if (versions.isSuccess) {
+            versions.getOrNull()?.doesServerSupportQrCodeLogin().orFalse()
+        } else {
+            false
+        }
+    }
+
+    override suspend fun loginUsingQrLoginToken(
+            homeServerConnectionConfig: HomeServerConnectionConfig,
+            loginToken: String,
+            initialDeviceName: String?,
+            deviceId: String?,
+    ): Session {
+        return qrLoginTokenTask.execute(
+                QrLoginTokenTask.Params(
+                        homeServerConnectionConfig = homeServerConnectionConfig,
+                        loginToken = loginToken,
+                        deviceName = initialDeviceName,
+                        deviceId = deviceId
+                )
+        )
+    }
+
     private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI {
         val retrofit = retrofitFactory.create(buildClient(homeServerConnectionConfig), homeServerConnectionConfig.homeServerUriBase.toString())
         return retrofit.create(AuthAPI::class.java)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginParams.kt
index ea8578ed8c5850efdb85be2bbbfe1fe25662a8dd..864675208371f80d89ca6089fb6cd25ad821719b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginParams.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginParams.kt
@@ -18,4 +18,6 @@ package org.matrix.android.sdk.internal.auth.data
 
 internal interface LoginParams {
     val type: String
+    val deviceDisplayName: String?
+    val deviceId: String?
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt
index 5f0a2298cb670acad891450f0a6a68adbea4bca7..062b2466e5549016613e08d4f9e889b4c4f01138 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt
@@ -30,8 +30,8 @@ internal data class PasswordLoginParams(
         @Json(name = "identifier") val identifier: Map<String, String>,
         @Json(name = "password") val password: String,
         @Json(name = "type") override val type: String,
-        @Json(name = "initial_device_display_name") val deviceDisplayName: String?,
-        @Json(name = "device_id") val deviceId: String?
+        @Json(name = "initial_device_display_name") override val deviceDisplayName: String?,
+        @Json(name = "device_id") override val deviceId: String?
 ) : LoginParams {
 
     companion object {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/TokenLoginParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/TokenLoginParams.kt
index 0c6fb45e78863f682c71c5fd9c06d145940291a6..52045a1d7a8699703f20d9d185b70979233b6b2d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/TokenLoginParams.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/TokenLoginParams.kt
@@ -23,5 +23,7 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
 @JsonClass(generateAdapter = true)
 internal data class TokenLoginParams(
         @Json(name = "type") override val type: String = LoginFlowTypes.TOKEN,
-        @Json(name = "token") val token: String
+        @Json(name = "token") val token: String,
+        @Json(name = "initial_device_display_name") override val deviceDisplayName: String? = null,
+        @Json(name = "device_id") override val deviceId: String? = null
 ) : LoginParams
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/QrLoginTokenTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/QrLoginTokenTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..477719f607ae8b9b70b280f4d9d4bd96e731cacf
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/QrLoginTokenTask.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.auth.login
+
+import dagger.Lazy
+import okhttp3.OkHttpClient
+import org.matrix.android.sdk.api.auth.LoginType
+import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
+import org.matrix.android.sdk.api.failure.Failure
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.internal.auth.AuthAPI
+import org.matrix.android.sdk.internal.auth.SessionCreator
+import org.matrix.android.sdk.internal.auth.data.TokenLoginParams
+import org.matrix.android.sdk.internal.di.Unauthenticated
+import org.matrix.android.sdk.internal.network.RetrofitFactory
+import org.matrix.android.sdk.internal.network.executeRequest
+import org.matrix.android.sdk.internal.network.httpclient.addSocketFactory
+import org.matrix.android.sdk.internal.network.ssl.UnrecognizedCertificateException
+import org.matrix.android.sdk.internal.task.Task
+import javax.inject.Inject
+
+internal interface QrLoginTokenTask : Task<QrLoginTokenTask.Params, Session> {
+    data class Params(
+            val homeServerConnectionConfig: HomeServerConnectionConfig,
+            val loginToken: String,
+            val deviceName: String?,
+            val deviceId: String?
+    )
+}
+
+internal class DefaultQrLoginTokenTask @Inject constructor(
+        @Unauthenticated
+        private val okHttpClient: Lazy<OkHttpClient>,
+        private val retrofitFactory: RetrofitFactory,
+        private val sessionCreator: SessionCreator,
+) : QrLoginTokenTask {
+
+    override suspend fun execute(params: QrLoginTokenTask.Params): Session {
+        val client = buildClient(params.homeServerConnectionConfig)
+        val homeServerUrl = params.homeServerConnectionConfig.homeServerUriBase.toString()
+
+        val authAPI = retrofitFactory.create(client, homeServerUrl)
+                .create(AuthAPI::class.java)
+
+        val loginParams = TokenLoginParams(
+                token = params.loginToken,
+                deviceDisplayName = params.deviceName,
+                deviceId = params.deviceId
+        )
+
+        val credentials = try {
+            executeRequest(null) {
+                authAPI.login(loginParams)
+            }
+        } catch (throwable: Throwable) {
+            throw when (throwable) {
+                is UnrecognizedCertificateException -> Failure.UnrecognizedCertificateFailure(
+                        homeServerUrl,
+                        throwable.fingerprint
+                )
+                else -> throwable
+            }
+        }
+
+        return sessionCreator.createSession(credentials, params.homeServerConnectionConfig, LoginType.QR)
+    }
+
+    private fun buildClient(homeServerConnectionConfig: HomeServerConnectionConfig): OkHttpClient {
+        return okHttpClient.get()
+                .newBuilder()
+                .addSocketFactory(homeServerConnectionConfig)
+                .build()
+    }
+}
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 915b25134b2f0f687a7a443796db800f50c6e0d2..5e133fab9ccaa0c3e627eb4107f51f3a535a6cd1 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
@@ -53,6 +53,7 @@ private const val FEATURE_ID_ACCESS_TOKEN = "m.id_access_token"
 private const val FEATURE_SEPARATE_ADD_AND_BIND = "m.separate_add_and_bind"
 private const val FEATURE_THREADS_MSC3440 = "org.matrix.msc3440"
 private const val FEATURE_THREADS_MSC3440_STABLE = "org.matrix.msc3440.stable"
+private const val FEATURE_QR_CODE_LOGIN = "org.matrix.msc3882"
 
 /**
  * Return true if the SDK supports this homeserver version.
@@ -78,6 +79,10 @@ internal fun Versions.doesServerSupportThreads(): Boolean {
     return unstableFeatures?.get(FEATURE_THREADS_MSC3440_STABLE) ?: false
 }
 
+internal fun Versions.doesServerSupportQrCodeLogin(): Boolean {
+    return unstableFeatures?.get(FEATURE_QR_CODE_LOGIN) ?: false
+}
+
 /**
  * Return true if the server support the lazy loading of room members.
  *
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt
index e97cf437c68836363a5e0d5ce2637d9703e740c3..1b52b79746e35d4293e6901d05fe03a1baa75cb4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt
@@ -657,14 +657,7 @@ internal class RealmCryptoStore @Inject constructor(
     }
 
     override fun saveMyDevicesInfo(info: List<DeviceInfo>) {
-        val entities = info.map {
-            MyDeviceLastSeenInfoEntity(
-                    lastSeenTs = it.lastSeenTs,
-                    lastSeenIp = it.lastSeenIp,
-                    displayName = it.displayName,
-                    deviceId = it.deviceId
-            )
-        }
+        val entities = info.map { myDeviceLastSeenInfoEntityMapper.map(it) }
         doRealmTransactionAsync(realmConfiguration) { realm ->
             realm.where<MyDeviceLastSeenInfoEntity>().findAll().deleteAllFromRealm()
             entities.forEach {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt
index de2b74308dd278883fe4effe3682ba10d150d74a..9129453c8a9dead961786a827140f21a21329bfc 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt
@@ -36,6 +36,7 @@ import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo
 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo017
 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo018
 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo019
+import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo020
 import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
 import org.matrix.android.sdk.internal.util.time.Clock
 import javax.inject.Inject
@@ -50,7 +51,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(
         private val clock: Clock,
 ) : MatrixRealmMigration(
         dbName = "Crypto",
-        schemaVersion = 19L,
+        schemaVersion = 20L,
 ) {
     /**
      * Forces all RealmCryptoStoreMigration instances to be equal.
@@ -79,5 +80,6 @@ internal class RealmCryptoStoreMigration @Inject constructor(
         if (oldVersion < 17) MigrateCryptoTo017(realm).perform()
         if (oldVersion < 18) MigrateCryptoTo018(realm).perform()
         if (oldVersion < 19) MigrateCryptoTo019(realm).perform()
+        if (oldVersion < 20) MigrateCryptoTo020(realm).perform()
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt
index 38a7569aabc30e441e4e48bc7c4b5c4e94dd7832..b81883fb3842ed09d75dbeb0b4be5f30125bb3dc 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt
@@ -27,7 +27,18 @@ internal class MyDeviceLastSeenInfoEntityMapper @Inject constructor() {
                 deviceId = entity.deviceId,
                 lastSeenIp = entity.lastSeenIp,
                 lastSeenTs = entity.lastSeenTs,
-                displayName = entity.displayName
+                displayName = entity.displayName,
+                unstableLastSeenUserAgent = entity.lastSeenUserAgent,
+        )
+    }
+
+    fun map(deviceInfo: DeviceInfo): MyDeviceLastSeenInfoEntity {
+        return MyDeviceLastSeenInfoEntity(
+                deviceId = deviceInfo.deviceId,
+                lastSeenIp = deviceInfo.lastSeenIp,
+                lastSeenTs = deviceInfo.lastSeenTs,
+                displayName = deviceInfo.displayName,
+                lastSeenUserAgent = deviceInfo.getBestLastSeenUserAgent(),
         )
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt
index 9d2eb60a600879508134c866f982897eb54e06e2..65280300ab3bc85509e27866581fbb51cf9dbde2 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt
@@ -30,7 +30,7 @@ import org.matrix.android.sdk.internal.util.database.RealmMigrator
  * mark existing keys as safe.
  * This migration can take long depending on the account
  */
-internal class MigrateCryptoTo019(realm: DynamicRealm) : RealmMigrator(realm, 18) {
+internal class MigrateCryptoTo019(realm: DynamicRealm) : RealmMigrator(realm, 19) {
 
     override fun doMigrate(realm: DynamicRealm) {
         realm.schema.get("CrossSigningInfoEntity")
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo020.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo020.kt
new file mode 100644
index 0000000000000000000000000000000000000000..44d07ab538a53f4dc7b71b091a9b882bc7523acf
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo020.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.crypto.store.db.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+/**
+ * This migration adds a new field into MyDeviceLastSeenInfoEntity corresponding to the last seen user agent.
+ */
+internal class MigrateCryptoTo020(realm: DynamicRealm) : RealmMigrator(realm, 20) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("MyDeviceLastSeenInfoEntity")
+                ?.addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_USER_AGENT, String::class.java)
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt
index 74a81d5b01a045328d01edd8b4749cb1a7782084..3e6dc2de16dc23c01548c2c015b6f7564be92c9d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt
@@ -27,7 +27,9 @@ internal open class MyDeviceLastSeenInfoEntity(
         /** The last time this device has been seen. */
         var lastSeenTs: Long? = null,
         /** The last ip address. */
-        var lastSeenIp: String? = null
+        var lastSeenIp: String? = null,
+        /** The last user agent. */
+        var lastSeenUserAgent: String? = null,
 ) : RealmObject() {
 
     companion object
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 2693ca474c21d8cf434b9c94f9ee876916a81a2b..9a2c32f97cdd982f677ef8b0ca6acd191f0dafbd 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
@@ -54,6 +54,8 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo034
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo035
 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.util.Normalizer
 import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
 import javax.inject.Inject
@@ -62,7 +64,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
         private val normalizer: Normalizer
 ) : MatrixRealmMigration(
         dbName = "Session",
-        schemaVersion = 37L,
+        schemaVersion = 39L,
 ) {
     /**
      * Forces all RealmSessionStoreMigration instances to be equal.
@@ -109,5 +111,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
         if (oldVersion < 35) MigrateSessionTo035(realm).perform()
         if (oldVersion < 36) MigrateSessionTo036(realm).perform()
         if (oldVersion < 37) MigrateSessionTo037(realm).perform()
+        if (oldVersion < 38) MigrateSessionTo038(realm).perform()
+        if (oldVersion < 39) MigrateSessionTo039(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 184a0108b910191cacf7380f48b7bf96f03230db..63fa101c45ce9c3f19f465090d49d2999d5cf474 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
@@ -43,7 +43,8 @@ internal object HomeServerCapabilitiesMapper {
                 defaultIdentityServerUrl = entity.defaultIdentityServerUrl,
                 roomVersions = mapRoomVersion(entity.roomVersionsJson),
                 canUseThreading = entity.canUseThreading,
-                canControlLogoutDevices = entity.canControlLogoutDevices
+                canControlLogoutDevices = entity.canControlLogoutDevices,
+                canLoginWithQrCode = entity.canLoginWithQrCode,
         )
     }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt
index 2dba2c228bb16421825f63dc1ac4c7ded3b4ecc6..c3a37f5b95bbb61b002b2c499f284f28043b6667 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt
@@ -33,7 +33,9 @@ internal object PushersMapper {
                 profileTag = pushEntity.profileTag,
                 lang = pushEntity.lang,
                 data = PusherData(pushEntity.data?.url, pushEntity.data?.format),
-                state = pushEntity.state
+                enabled = pushEntity.enabled,
+                deviceId = pushEntity.deviceId,
+                state = pushEntity.state,
         )
     }
 
@@ -46,7 +48,9 @@ internal object PushersMapper {
                 deviceDisplayName = pusher.deviceDisplayName,
                 profileTag = pusher.profileTag,
                 lang = pusher.lang,
-                data = PusherDataEntity(pusher.data?.url, pusher.data?.format)
+                data = PusherDataEntity(pusher.data?.url, pusher.data?.format),
+                enabled = pusher.enabled,
+                deviceId = pusher.deviceId,
         )
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo038.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo038.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b5848f04cdb982f8f34a4bf39a9206e3d63b6bf9
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo038.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.PusherEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo038(realm: DynamicRealm) : RealmMigrator(realm, 38) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("PusherEntity")
+                ?.addField(PusherEntityFields.ENABLED, Boolean::class.java)
+                ?.addField(PusherEntityFields.DEVICE_ID, String::class.java)
+                ?.transform { obj -> obj.set(PusherEntityFields.ENABLED, true) }
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo039.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo039.kt
new file mode 100644
index 0000000000000000000000000000000000000000..190a71c9be949a9bbfd62d1f78c281f082b759d2
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo039.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 MigrateSessionTo039(realm: DynamicRealm) : RealmMigrator(realm, 39) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("HomeServerCapabilitiesEntity")
+                ?.addField(HomeServerCapabilitiesEntityFields.CAN_LOGIN_WITH_QR_CODE, Boolean::class.java)
+                ?.transform { obj ->
+                    obj.set(HomeServerCapabilitiesEntityFields.CAN_LOGIN_WITH_QR_CODE, false)
+                }
+                ?.forceRefreshOfHomeServerCapabilities()
+    }
+}
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 9d90973f8abded22d4ccb9b017badfc5a8351ed7..cfa02b2c743df39ed1eb67350f12ed53f5f6888a 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
@@ -30,7 +30,8 @@ internal open class HomeServerCapabilitiesEntity(
         var defaultIdentityServerUrl: String? = null,
         var lastUpdatedTimestamp: Long = 0L,
         var canUseThreading: Boolean = false,
-        var canControlLogoutDevices: Boolean = false
+        var canControlLogoutDevices: Boolean = false,
+        var canLoginWithQrCode: Boolean = false,
 ) : RealmObject() {
 
     companion object
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt
index af8e4f2d370ee62c03e1234a6b666846d921c9f9..c08f6951683cdcbfc1dda24e33021bdae4e997dc 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt
@@ -18,15 +18,6 @@ package org.matrix.android.sdk.internal.database.model
 import io.realm.RealmObject
 import org.matrix.android.sdk.api.session.pushers.PusherState
 
-// TODO
-//        at java.lang.Thread.run(Thread.java:764)
-//     Caused by: java.lang.IllegalArgumentException: 'value' is not a valid managed object.
-//        at io.realm.ProxyState.checkValidObject(ProxyState.java:213)
-//        at io.realm.im_vector_matrix_android_internal_database_model_PusherEntityRealmProxy
-//            .realmSet$data(im_vector_matrix_android_internal_database_model_PusherEntityRealmProxy.java:413)
-//        at org.matrix.android.sdk.internal.database.model.PusherEntity.setData(PusherEntity.kt:16)
-//        at org.matrix.android.sdk.internal.session.pushers.AddHttpPusherWorker$doWork$$inlined$fold$lambda$2.execute(AddHttpPusherWorker.kt:70)
-//        at io.realm.Realm.executeTransaction(Realm.java:1493)
 internal open class PusherEntity(
         var pushKey: String = "",
         var kind: String? = null,
@@ -35,7 +26,9 @@ internal open class PusherEntity(
         var deviceDisplayName: String? = null,
         var profileTag: String? = null,
         var lang: String? = null,
-        var data: PusherDataEntity? = null
+        var data: PusherDataEntity? = null,
+        var enabled: Boolean = true,
+        var deviceId: String? = null,
 ) : RealmObject() {
     private var stateStr: String = PusherState.UNREGISTERED.name
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCase.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCase.kt
index 6eb4d5b1042263b359c87a41481674ea967e2b26..04e8cf8a295a9f2b99fa0078584ae3ea2ed25f55 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCase.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCase.kt
@@ -20,6 +20,8 @@ import android.content.Context
 import android.os.Build
 import org.matrix.android.sdk.BuildConfig
 import org.matrix.android.sdk.api.extensions.tryOrNull
+import org.matrix.android.sdk.api.util.getApplicationInfoCompat
+import org.matrix.android.sdk.api.util.getPackageInfoCompat
 import javax.inject.Inject
 
 class ComputeUserAgentUseCase @Inject constructor(
@@ -36,7 +38,7 @@ class ComputeUserAgentUseCase @Inject constructor(
         val appPackageName = context.applicationContext.packageName
         val pm = context.packageManager
 
-        val appName = tryOrNull { pm.getApplicationLabel(pm.getApplicationInfo(appPackageName, 0)).toString() }
+        val appName = tryOrNull { pm.getApplicationLabel(pm.getApplicationInfoCompat(appPackageName, 0)).toString() }
                 ?.takeIf {
                     it.matches("\\A\\p{ASCII}*\\z".toRegex())
                 }
@@ -44,7 +46,7 @@ class ComputeUserAgentUseCase @Inject constructor(
                     // Use appPackageName instead of appName if appName is null or contains any non-ASCII character
                     appPackageName
                 }
-        val appVersion = tryOrNull { pm.getPackageInfo(context.applicationContext.packageName, 0).versionName } ?: FALLBACK_APP_VERSION
+        val appVersion = tryOrNull { pm.getPackageInfoCompat(context.applicationContext.packageName, 0).versionName } ?: FALLBACK_APP_VERSION
 
         val deviceManufacturer = Build.MANUFACTURER
         val deviceModel = Build.MODEL
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt
index c023646c7fa6e57f0b0f11f97dddb6b4cc7f6af9..eee55735e0a7be73207678b8bbafe06c3ff7b7c9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt
@@ -130,6 +130,7 @@ internal class FileUploader @Inject constructor(
             workingFile.outputStream().use {
                 inputStream.copyTo(it)
             }
+            inputStream.close()
             workingFile
         }
     }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt
index 1e62b5d7f5b2e63622b4c4aa437cd02617326140..db1cd1b33bbf662757a19732ec29e2bfde65aa77 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt
@@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.api.listeners.ProgressListener
 import org.matrix.android.sdk.api.session.content.ContentAttachmentData
 import org.matrix.android.sdk.api.session.crypto.model.EncryptedFileInfo
+import org.matrix.android.sdk.api.session.events.model.Content
 import org.matrix.android.sdk.api.session.events.model.toContent
 import org.matrix.android.sdk.api.session.events.model.toModel
 import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
@@ -407,7 +408,10 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
             newAttachmentAttributes: NewAttachmentAttributes
     ) {
         localEchoRepository.updateEcho(eventId) { _, event ->
-            val messageContent: MessageContent? = event.asDomain().content.toModel()
+            val content: Content? = event.asDomain().content
+            val messageContent: MessageContent? = content.toModel()
+            // Retrieve potential additional content from the original event
+            val additionalContent = content.orEmpty() - messageContent?.toContent().orEmpty().keys
             val updatedContent = when (messageContent) {
                 is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes)
                 is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, newAttachmentAttributes)
@@ -415,7 +419,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
                 is MessageAudioContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize)
                 else -> messageContent
             }
-            event.content = ContentMapper.map(updatedContent.toContent())
+            event.content = ContentMapper.map(updatedContent.toContent().plus(additionalContent))
         }
     }
 
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 add69dd8c7d90e387909626283f3bb6358454b8a..2c3cb440b62a984d8e993e3c6938852bb94c7222 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
@@ -20,11 +20,11 @@ import com.zhuinden.monarchy.Monarchy
 import org.matrix.android.sdk.api.MatrixPatterns.getServerName
 import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
 import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
-import org.matrix.android.sdk.api.extensions.orFalse
 import org.matrix.android.sdk.api.extensions.orTrue
 import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
 import org.matrix.android.sdk.internal.auth.version.Versions
 import org.matrix.android.sdk.internal.auth.version.doesServerSupportLogoutDevices
+import org.matrix.android.sdk.internal.auth.version.doesServerSupportQrCodeLogin
 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
@@ -132,8 +132,6 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
                 homeServerCapabilitiesEntity.roomVersionsJson = capabilities?.roomVersions?.let {
                     MoshiProvider.providesMoshi().adapter(RoomVersions::class.java).toJson(it)
                 }
-                homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */
-                        getVersionResult?.doesServerSupportThreads().orFalse()
             }
 
             if (getMediaConfigResult != null) {
@@ -144,6 +142,9 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
             if (getVersionResult != null) {
                 homeServerCapabilitiesEntity.lastVersionIdentityServerSupported = getVersionResult.isLoginAndRegistrationSupportedBySdk()
                 homeServerCapabilitiesEntity.canControlLogoutDevices = getVersionResult.doesServerSupportLogoutDevices()
+                homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */
+                        getVersionResult.doesServerSupportThreads()
+                homeServerCapabilitiesEntity.canLoginWithQrCode = getVersionResult.doesServerSupportQrCodeLogin()
             }
 
             if (getWellknownResult != null && getWellknownResult is WellknownResult.Prompt) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt
index 40444edcab33143960c56678f460e7b6cffd875a..22bb3d37b04623de6ccf2f202dc9b78db30fb637 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt
@@ -17,26 +17,40 @@
 
 package org.matrix.android.sdk.internal.session.profile
 
+import com.zhuinden.monarchy.Monarchy
+import org.matrix.android.sdk.api.session.user.model.User
 import org.matrix.android.sdk.api.util.JsonDict
+import org.matrix.android.sdk.internal.di.SessionDatabase
 import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
 import org.matrix.android.sdk.internal.network.executeRequest
+import org.matrix.android.sdk.internal.session.user.UserEntityFactory
 import org.matrix.android.sdk.internal.task.Task
+import org.matrix.android.sdk.internal.util.awaitTransaction
 import javax.inject.Inject
 
 internal abstract class GetProfileInfoTask : Task<GetProfileInfoTask.Params, JsonDict> {
     data class Params(
-            val userId: String
+            val userId: String,
+            val storeInDatabase: Boolean = true,
     )
 }
 
 internal class DefaultGetProfileInfoTask @Inject constructor(
         private val profileAPI: ProfileAPI,
-        private val globalErrorReceiver: GlobalErrorReceiver
+        private val globalErrorReceiver: GlobalErrorReceiver,
+        @SessionDatabase private val monarchy: Monarchy,
 ) : GetProfileInfoTask() {
 
     override suspend fun execute(params: Params): JsonDict {
         return executeRequest(globalErrorReceiver) {
             profileAPI.getProfile(params.userId)
+        }.also { user ->
+            if (params.storeInDatabase) {
+                // Insert into DB
+                monarchy.awaitTransaction {
+                    it.insertOrUpdate(UserEntityFactory.create(User.fromJson(params.userId, user)))
+                }
+            }
         }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt
index 7d81e19265c3eef69921db05e216bd51c72209d3..3e145dc6682232ce835fe97c80965b942ea53dac 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt
@@ -38,6 +38,7 @@ internal class DefaultAddPusherTask @Inject constructor(
         private val requestExecutor: RequestExecutor,
         private val globalErrorReceiver: GlobalErrorReceiver
 ) : AddPusherTask {
+
     override suspend fun execute(params: AddPusherTask.Params) {
         val pusher = params.pusher
         try {
@@ -71,6 +72,8 @@ internal class DefaultAddPusherTask @Inject constructor(
                 echo.profileTag = pusher.profileTag
                 echo.data?.format = pusher.data?.format
                 echo.data?.url = pusher.data?.url
+                echo.enabled = pusher.enabled
+                echo.deviceId = pusher.deviceId
                 echo.state = PusherState.REGISTERED
             }
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt
index e912d9ccf888986f31ea4fefd934224c84e2c192..e89cfa508c6b1c65197c6e97726df6965c22a7f7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt
@@ -42,6 +42,7 @@ internal class DefaultPushersService @Inject constructor(
         private val getPusherTask: GetPushersTask,
         private val pushGatewayNotifyTask: PushGatewayNotifyTask,
         private val addPusherTask: AddPusherTask,
+        private val togglePusherTask: TogglePusherTask,
         private val removePusherTask: RemovePusherTask,
         private val taskExecutor: TaskExecutor
 ) : PushersService {
@@ -78,7 +79,9 @@ internal class DefaultPushersService @Inject constructor(
             appDisplayName = appDisplayName,
             deviceDisplayName = deviceDisplayName,
             data = JsonPusherData(url, EVENT_ID_ONLY.takeIf { withEventIdOnly }),
-            append = append
+            append = append,
+            enabled = enabled,
+            deviceId = deviceId,
     )
 
     override suspend fun addEmailPusher(
@@ -106,6 +109,24 @@ internal class DefaultPushersService @Inject constructor(
         )
     }
 
+    override suspend fun togglePusher(pusher: Pusher, enable: Boolean) {
+        togglePusherTask.execute(TogglePusherTask.Params(pusher.toJsonPusher(), enable))
+    }
+
+    private fun Pusher.toJsonPusher() = JsonPusher(
+            pushKey = pushKey,
+            kind = kind,
+            appId = appId,
+            appDisplayName = appDisplayName,
+            deviceDisplayName = deviceDisplayName,
+            profileTag = profileTag,
+            lang = lang,
+            data = JsonPusherData(data.url, data.format),
+            append = false,
+            enabled = enabled,
+            deviceId = deviceId,
+    )
+
     private fun enqueueAddPusher(pusher: JsonPusher): UUID {
         val params = AddPusherWorker.Params(sessionId, pusher)
         val request = workManagerProvider.matrixOneTimeWorkRequestBuilder<AddPusherWorker>()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt
index 71a1ea8c6669471d191b44c8c2e068c6c186d8ef..c1cf3eb276c6ddfc9e0666a148d218581ef454f7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt
@@ -33,6 +33,8 @@ import java.security.InvalidParameterException
  *              "device_display_name": "Alice's Phone",
  *              "profile_tag": "xyz",
  *              "lang": "en-US",
+ *              "enabled": true,
+ *              "device_id": "abc123",
  *              "data": {
  *              "url": "https://example.com/_matrix/push/v1/notify"
  *          }
@@ -112,7 +114,19 @@ internal data class JsonPusher(
          * The default is false.
          */
         @Json(name = "append")
-        val append: Boolean? = false
+        val append: Boolean? = false,
+
+        /**
+         * Whether the pusher should actively create push notifications.
+         */
+        @Json(name = "org.matrix.msc3881.enabled")
+        val enabled: Boolean = true,
+
+        /**
+         * The device_id of the session that registered the pusher.
+         */
+        @Json(name = "org.matrix.msc3881.device_id")
+        val deviceId: String? = null,
 ) {
     init {
         // Do some parameter checks. It's ok to throw Exception, to inform developer of the problem
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt
index 4528c95e6914d88e2b90b88edfc7d7221fb1a4ec..37c1c0c3ad7515d22f7ead81a52a7741367b132c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt
@@ -68,6 +68,9 @@ internal abstract class PushersModule {
     @Binds
     abstract fun bindAddPusherTask(task: DefaultAddPusherTask): AddPusherTask
 
+    @Binds
+    abstract fun bindTogglePusherTask(task: DefaultTogglePusherTask): TogglePusherTask
+
     @Binds
     abstract fun bindRemovePusherTask(task: DefaultRemovePusherTask): RemovePusherTask
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/TogglePusherTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/TogglePusherTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..87836e1c769e01ecf3d9470bb6d990dcb528fb4f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/TogglePusherTask.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2021 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.session.pushers
+
+import com.zhuinden.monarchy.Monarchy
+import org.matrix.android.sdk.internal.database.model.PusherEntity
+import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.di.SessionDatabase
+import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
+import org.matrix.android.sdk.internal.network.RequestExecutor
+import org.matrix.android.sdk.internal.task.Task
+import org.matrix.android.sdk.internal.util.awaitTransaction
+import javax.inject.Inject
+
+internal interface TogglePusherTask : Task<TogglePusherTask.Params, Unit> {
+    data class Params(val pusher: JsonPusher, val enable: Boolean)
+}
+
+internal class DefaultTogglePusherTask @Inject constructor(
+        private val pushersAPI: PushersAPI,
+        @SessionDatabase private val monarchy: Monarchy,
+        private val requestExecutor: RequestExecutor,
+        private val globalErrorReceiver: GlobalErrorReceiver
+) : TogglePusherTask {
+
+    override suspend fun execute(params: TogglePusherTask.Params) {
+        val pusher = params.pusher.copy(enabled = params.enable)
+
+        requestExecutor.executeRequest(globalErrorReceiver) {
+            pushersAPI.setPusher(pusher)
+        }
+
+        monarchy.awaitTransaction { realm ->
+            val entity = PusherEntity.where(realm, params.pusher.pushKey).findFirst()
+            entity?.apply { enabled = params.enable }?.let { realm.insertOrUpdate(it) }
+        }
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt
index 9839a44427fcd5939e80ab13a8753528052ab85b..ddf3e41dff231b3f0d7e85abd2675ac210efba09 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt
@@ -105,19 +105,21 @@ internal class DefaultRelationService @AssistedInject constructor(
             targetEvent: TimelineEvent,
             msgType: String,
             newBodyText: CharSequence,
+            newFormattedBodyText: CharSequence?,
             newBodyAutoMarkdown: Boolean,
             compatibilityBodyText: String
     ): Cancelable {
-        return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newBodyAutoMarkdown, compatibilityBodyText)
+        return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newFormattedBodyText, newBodyAutoMarkdown, compatibilityBodyText)
     }
 
     override fun editReply(
             replyToEdit: TimelineEvent,
             originalTimelineEvent: TimelineEvent,
             newBodyText: String,
+            newFormattedBodyText: String?,
             compatibilityBodyText: String
     ): Cancelable {
-        return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, compatibilityBodyText)
+        return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, newFormattedBodyText, compatibilityBodyText)
     }
 
     override suspend fun fetchEditHistory(eventId: String): List<Event> {
@@ -127,6 +129,7 @@ internal class DefaultRelationService @AssistedInject constructor(
     override fun replyToMessage(
             eventReplied: TimelineEvent,
             replyText: CharSequence,
+            replyFormattedText: CharSequence?,
             autoMarkdown: Boolean,
             showInThread: Boolean,
             rootThreadEventId: String?
@@ -135,6 +138,7 @@ internal class DefaultRelationService @AssistedInject constructor(
                 roomId = roomId,
                 eventReplied = eventReplied,
                 replyText = replyText,
+                replyTextFormatted = replyFormattedText,
                 autoMarkdown = autoMarkdown,
                 rootThreadEventId = rootThreadEventId,
                 showInThread = showInThread
@@ -178,6 +182,7 @@ internal class DefaultRelationService @AssistedInject constructor(
                     roomId = roomId,
                     eventReplied = eventReplied,
                     replyText = replyInThreadText,
+                    replyTextFormatted = formattedText,
                     autoMarkdown = autoMarkdown,
                     rootThreadEventId = rootThreadEventId,
                     showInThread = false
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt
index 795e9003ce6baaaed8b3c21fe6b04fc79926e613..c83539c8fdb7e1ef6f9974ec463efdca847a0bdd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt
@@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 import org.matrix.android.sdk.api.util.Cancelable
 import org.matrix.android.sdk.api.util.NoOpCancellable
+import org.matrix.android.sdk.api.util.TextContent
 import org.matrix.android.sdk.internal.database.mapper.toEntity
 import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
 import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
@@ -42,19 +43,25 @@ internal class EventEditor @Inject constructor(
             targetEvent: TimelineEvent,
             msgType: String,
             newBodyText: CharSequence,
+            newBodyFormattedText: CharSequence?,
             newBodyAutoMarkdown: Boolean,
             compatibilityBodyText: String
     ): Cancelable {
         val roomId = targetEvent.roomId
         if (targetEvent.root.sendState.hasFailed()) {
             // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event.
-            val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy(
+            val editedEvent = if (newBodyFormattedText != null) {
+                val content = TextContent(newBodyText.toString(), newBodyFormattedText.toString())
+                eventFactory.createFormattedTextEvent(roomId, content, msgType)
+            } else {
+                eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown)
+            }.copy(
                     eventId = targetEvent.eventId
             )
             return sendFailedEvent(targetEvent, editedEvent)
         } else if (targetEvent.root.sendState.isSent()) {
             val event = eventFactory
-                    .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText)
+                    .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyFormattedText, newBodyAutoMarkdown, msgType, compatibilityBodyText)
             return sendReplaceEvent(event)
         } else {
             // Should we throw?
@@ -100,6 +107,7 @@ internal class EventEditor @Inject constructor(
             replyToEdit: TimelineEvent,
             originalTimelineEvent: TimelineEvent,
             newBodyText: String,
+            newBodyFormattedText: String?,
             compatibilityBodyText: String
     ): Cancelable {
         val roomId = replyToEdit.roomId
@@ -109,6 +117,7 @@ internal class EventEditor @Inject constructor(
                     roomId = roomId,
                     eventReplied = originalTimelineEvent,
                     replyText = newBodyText,
+                    replyTextFormatted = newBodyFormattedText,
                     autoMarkdown = false,
                     showInThread = false
             )?.copy(
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt
index 5f5c000171aeb122cbb525a618dbc1e74c7dec29..93c7f143fd3c6ca6c5cb53e33dd3a91321ffb96a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt
@@ -22,6 +22,7 @@ import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider
 import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
 import org.matrix.android.sdk.internal.network.executeRequest
 import org.matrix.android.sdk.internal.session.room.RoomAPI
+import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDataSource
 import org.matrix.android.sdk.internal.task.Task
 import javax.inject.Inject
 
@@ -35,7 +36,8 @@ internal interface FetchEditHistoryTask : Task<FetchEditHistoryTask.Params, List
 internal class DefaultFetchEditHistoryTask @Inject constructor(
         private val roomAPI: RoomAPI,
         private val globalErrorReceiver: GlobalErrorReceiver,
-        private val cryptoSessionInfoProvider: CryptoSessionInfoProvider
+        private val cryptoSessionInfoProvider: CryptoSessionInfoProvider,
+        private val eventDataSource: TimelineEventDataSource,
 ) : FetchEditHistoryTask {
 
     override suspend fun execute(params: FetchEditHistoryTask.Params): List<Event> {
@@ -50,10 +52,14 @@ internal class DefaultFetchEditHistoryTask @Inject constructor(
         }
 
         // Filter out edition form other users, and redacted editions
-        val originalSenderId = response.originalEvent?.senderId
+        val originalEvent = eventDataSource.getTimelineEvent(
+                roomId = params.roomId,
+                eventId = params.eventId,
+        )
+        val originalSenderId = originalEvent?.senderInfo?.userId
         val events = response.chunks
                 .filter { it.senderId == originalSenderId }
                 .filter { !it.isRedacted() }
-        return events + listOfNotNull(response.originalEvent)
+        return events + listOfNotNull(originalEvent?.root)
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/RelationsResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/RelationsResponse.kt
index a65165d457de63261d9db5eb4b3ec0ad94dd00bb..f2b0651e01297c64bb9ecd58ff94cdb401d2984f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/RelationsResponse.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/RelationsResponse.kt
@@ -22,7 +22,6 @@ import org.matrix.android.sdk.api.session.events.model.Event
 @JsonClass(generateAdapter = true)
 internal data class RelationsResponse(
         @Json(name = "chunk") val chunks: List<Event>,
-        @Json(name = "original_event") val originalEvent: Event?,
         @Json(name = "next_batch") val nextBatch: String?,
         @Json(name = "prev_batch") val prevBatch: String?
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
index edd74c2ce04a2013c0ec4b2685dc378cabdb7f79..4cf6445920cb76ae3c646e4d17c3ae6a612c0363 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
@@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.relation.threads
 
 import com.zhuinden.monarchy.Monarchy
 import io.realm.Realm
+import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.api.session.crypto.MXCryptoError
 import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
 import org.matrix.android.sdk.api.session.events.model.Event
@@ -24,6 +25,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
 import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
+import org.matrix.android.sdk.internal.database.RealmSessionProvider
 import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
 import org.matrix.android.sdk.internal.database.mapper.asDomain
 import org.matrix.android.sdk.internal.database.mapper.toEntity
@@ -46,6 +48,7 @@ import org.matrix.android.sdk.internal.network.executeRequest
 import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent
 import org.matrix.android.sdk.internal.session.room.RoomAPI
 import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
+import org.matrix.android.sdk.internal.session.room.timeline.GetEventTask
 import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
 import org.matrix.android.sdk.internal.task.Task
 import org.matrix.android.sdk.internal.util.awaitTransaction
@@ -87,6 +90,8 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor(
         @SessionDatabase private val monarchy: Monarchy,
         private val cryptoService: DefaultCryptoService,
         private val clock: Clock,
+        private val realmSessionProvider: RealmSessionProvider,
+        private val getEventTask: GetEventTask,
 ) : FetchThreadTimelineTask {
 
     enum class Result {
@@ -114,11 +119,26 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor(
             params: FetchThreadTimelineTask.Params
     ): Result {
         val threadList = response.chunks
-        val threadRootEvent = response.originalEvent
         val hasReachEnd = response.nextBatch == null
 
-        monarchy.awaitTransaction { realm ->
+        val isRootThreadTimelineEventEntityKnown: Boolean
+        var threadRootEvent: Event? = null
 
+        if (hasReachEnd) {
+            isRootThreadTimelineEventEntityKnown = realmSessionProvider.withRealm { realm ->
+                TimelineEventEntity
+                        .where(realm, roomId = params.roomId, eventId = params.rootThreadEventId)
+                        .findFirst()
+            } != null
+            if (!isRootThreadTimelineEventEntityKnown) {
+                // Fetch the root event from the server
+                threadRootEvent = tryOrNull {
+                    getEventTask.execute(GetEventTask.Params(roomId = params.roomId, eventId = params.rootThreadEventId))
+                }
+            }
+        }
+
+        monarchy.awaitTransaction { realm ->
             val threadChunk = ChunkEntity.findLastForwardChunkOfThread(realm, params.roomId, params.rootThreadEventId)
                     ?: run {
                         return@awaitTransaction
@@ -173,7 +193,7 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor(
                     // Case when thread event is not in the device
                     Timber.i("###THREADS FetchThreadTimelineTask root thread event: ${params.rootThreadEventId} NOT FOUND! Lets create a temp one")
                     val eventEntity = createEventEntity(params.roomId, threadRootEvent, realm)
-                    roomMemberContentsByUser.addSenderState(realm, params.roomId, threadRootEvent.senderId)
+                    roomMemberContentsByUser.addSenderState(realm, params.roomId, threadRootEvent.senderId!!)
                     threadChunk.addTimelineEvent(
                             roomId = params.roomId,
                             eventEntity = eventEntity,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
index 418000abed9037e598550f14533a94234b111fa0..9cdbc7ff463cbba0449d8e2df10cacd5ac6cb5cb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
@@ -27,6 +27,7 @@ import dagger.assisted.AssistedInject
 import kotlinx.coroutines.launch
 import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
 import org.matrix.android.sdk.api.session.content.ContentAttachmentData
+import org.matrix.android.sdk.api.session.events.model.Content
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage
 import org.matrix.android.sdk.api.session.events.model.isTextMessage
@@ -39,6 +40,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
 import org.matrix.android.sdk.api.session.room.model.message.PollType
 import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
+import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
 import org.matrix.android.sdk.api.session.room.send.SendService
 import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
@@ -87,51 +89,60 @@ internal class DefaultSendService @AssistedInject constructor(
                 .let { sendEvent(it) }
     }
 
-    override fun sendTextMessage(text: CharSequence, msgType: String, autoMarkdown: Boolean): Cancelable {
-        return localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown)
+    override fun sendTextMessage(text: CharSequence, msgType: String, autoMarkdown: Boolean, additionalContent: Content?): Cancelable {
+        return localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown, additionalContent)
                 .also { createLocalEcho(it) }
                 .let { sendEvent(it) }
     }
 
-    override fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String): Cancelable {
-        return localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType)
+    override fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String, additionalContent: Content?): Cancelable {
+        return localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType, additionalContent)
                 .also { createLocalEcho(it) }
                 .let { sendEvent(it) }
     }
 
-    override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable {
+    override fun sendQuotedTextMessage(
+            quotedEvent: TimelineEvent,
+            text: String,
+            formattedText: String?,
+            autoMarkdown: Boolean,
+            rootThreadEventId: String?,
+            additionalContent: Content?,
+    ): Cancelable {
         return localEchoEventFactory.createQuotedTextEvent(
                 roomId = roomId,
                 quotedEvent = quotedEvent,
                 text = text,
+                formattedText = formattedText,
                 autoMarkdown = autoMarkdown,
-                rootThreadEventId = rootThreadEventId
+                rootThreadEventId = rootThreadEventId,
+                additionalContent = additionalContent,
         )
                 .also { createLocalEcho(it) }
                 .let { sendEvent(it) }
     }
 
-    override fun sendPoll(pollType: PollType, question: String, options: List<String>): Cancelable {
-        return localEchoEventFactory.createPollEvent(roomId, pollType, question, options)
+    override fun sendPoll(pollType: PollType, question: String, options: List<String>, additionalContent: Content?): Cancelable {
+        return localEchoEventFactory.createPollEvent(roomId, pollType, question, options, additionalContent)
                 .also { createLocalEcho(it) }
                 .let { sendEvent(it) }
     }
 
-    override fun voteToPoll(pollEventId: String, answerId: String): Cancelable {
-        return localEchoEventFactory.createPollReplyEvent(roomId, pollEventId, answerId)
+    override fun voteToPoll(pollEventId: String, answerId: String, additionalContent: Content?): Cancelable {
+        return localEchoEventFactory.createPollReplyEvent(roomId, pollEventId, answerId, additionalContent)
                 .also { createLocalEcho(it) }
                 .let { sendEvent(it) }
     }
 
-    override fun endPoll(pollEventId: String): Cancelable {
-        return localEchoEventFactory.createEndPollEvent(roomId, pollEventId)
+    override fun endPoll(pollEventId: String, additionalContent: Content?): Cancelable {
+        return localEchoEventFactory.createEndPollEvent(roomId, pollEventId, additionalContent)
                 .also { createLocalEcho(it) }
                 .let { sendEvent(it) }
     }
 
-    override fun redactEvent(event: Event, reason: String?): Cancelable {
+    override fun redactEvent(event: Event, reason: String?, additionalContent: Content?): Cancelable {
         // TODO manage media/attachements?
-        val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason)
+        val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason, additionalContent)
                 .also { createLocalEcho(it) }
         return eventSenderProcessor.postRedaction(redactionEcho, reason)
     }
@@ -257,7 +268,8 @@ internal class DefaultSendService @AssistedInject constructor(
             attachments: List<ContentAttachmentData>,
             compressBeforeSending: Boolean,
             roomIds: Set<String>,
-            rootThreadEventId: String?
+            rootThreadEventId: String?,
+            additionalContent: Content?,
     ): Cancelable {
         return attachments.mapTo(CancelableBag()) {
             sendMedia(
@@ -273,7 +285,9 @@ internal class DefaultSendService @AssistedInject constructor(
             attachment: ContentAttachmentData,
             compressBeforeSending: Boolean,
             roomIds: Set<String>,
-            rootThreadEventId: String?
+            rootThreadEventId: String?,
+            relatesTo: RelationDefaultContent?,
+            additionalContent: Content?,
     ): Cancelable {
         // Ensure that the event will not be send in a thread if we are a different flow.
         // Like sending files to multiple rooms
@@ -288,7 +302,9 @@ internal class DefaultSendService @AssistedInject constructor(
             localEchoEventFactory.createMediaEvent(
                     roomId = it,
                     attachment = attachment,
-                    rootThreadEventId = rootThreadId
+                    rootThreadEventId = rootThreadId,
+                    relatesTo,
+                    additionalContent,
             ).also { event ->
                 createLocalEcho(event)
             }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
index 4fbc91e9ec2d788036ce1ac3c5ca92d7af261458..7d8605c2bd399abd1291dcdc4eaacaabefb4abaa 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
@@ -95,12 +95,12 @@ internal class LocalEchoEventFactory @Inject constructor(
         private val permalinkFactory: PermalinkFactory,
         private val clock: Clock,
 ) {
-    fun createTextEvent(roomId: String, msgType: String, text: CharSequence, autoMarkdown: Boolean): Event {
+    fun createTextEvent(roomId: String, msgType: String, text: CharSequence, autoMarkdown: Boolean, additionalContent: Content? = null): Event {
         if (msgType == MessageType.MSGTYPE_TEXT || msgType == MessageType.MSGTYPE_EMOTE) {
-            return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown), msgType)
+            return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown), msgType, additionalContent)
         }
         val content = MessageTextContent(msgType = msgType, body = text.toString())
-        return createMessageEvent(roomId, content)
+        return createMessageEvent(roomId, content, additionalContent)
     }
 
     private fun createTextContent(text: CharSequence, autoMarkdown: Boolean): TextContent {
@@ -116,35 +116,41 @@ internal class LocalEchoEventFactory @Inject constructor(
         return TextContent(text.toString())
     }
 
-    fun createFormattedTextEvent(roomId: String, textContent: TextContent, msgType: String): Event {
-        return createMessageEvent(roomId, textContent.toMessageTextContent(msgType))
+    fun createFormattedTextEvent(roomId: String, textContent: TextContent, msgType: String, additionalContent: Content? = null): Event {
+        return createMessageEvent(roomId, textContent.toMessageTextContent(msgType), additionalContent)
     }
 
     fun createReplaceTextEvent(
             roomId: String,
             targetEventId: String,
             newBodyText: CharSequence,
+            newBodyFormattedText: CharSequence?,
             newBodyAutoMarkdown: Boolean,
             msgType: String,
-            compatibilityText: String
+            compatibilityText: String,
+            additionalContent: Content? = null,
     ): Event {
+        val content = if (newBodyFormattedText != null) {
+            TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType)
+        } else {
+            createTextContent(newBodyText, newBodyAutoMarkdown).toMessageTextContent(msgType)
+        }.toContent()
         return createMessageEvent(
                 roomId,
                 MessageTextContent(
                         msgType = msgType,
                         body = compatibilityText,
                         relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
-                        newContent = createTextContent(newBodyText, newBodyAutoMarkdown)
-                                .toMessageTextContent(msgType)
-                                .toContent()
-                )
+                        newContent = content,
+                ),
+                additionalContent,
         )
     }
 
     private fun createPollContent(
             question: String,
             options: List<String>,
-            pollType: PollType
+            pollType: PollType,
     ): MessagePollContent {
         return MessagePollContent(
                 unstablePollCreationInfo = PollCreationInfo(
@@ -162,7 +168,8 @@ internal class LocalEchoEventFactory @Inject constructor(
             pollType: PollType,
             targetEventId: String,
             question: String,
-            options: List<String>
+            options: List<String>,
+            additionalContent: Content? = null,
     ): Event {
         val newContent = MessagePollContent(
                 relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
@@ -175,14 +182,15 @@ internal class LocalEchoEventFactory @Inject constructor(
                 senderId = userId,
                 eventId = localId,
                 type = EventType.POLL_START.first(),
-                content = newContent.toContent()
+                content = newContent.toContent().plus(additionalContent.orEmpty())
         )
     }
 
     fun createPollReplyEvent(
             roomId: String,
             pollEventId: String,
-            answerId: String
+            answerId: String,
+            additionalContent: Content? = null,
     ): Event {
         val content = MessagePollResponseContent(
                 body = answerId,
@@ -199,7 +207,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 senderId = userId,
                 eventId = localId,
                 type = EventType.POLL_RESPONSE.first(),
-                content = content.toContent(),
+                content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
     }
@@ -208,7 +216,8 @@ internal class LocalEchoEventFactory @Inject constructor(
             roomId: String,
             pollType: PollType,
             question: String,
-            options: List<String>
+            options: List<String>,
+            additionalContent: Content? = null,
     ): Event {
         val content = createPollContent(question, options, pollType)
         val localId = LocalEcho.createLocalEchoId()
@@ -218,14 +227,15 @@ internal class LocalEchoEventFactory @Inject constructor(
                 senderId = userId,
                 eventId = localId,
                 type = EventType.POLL_START.first(),
-                content = content.toContent(),
+                content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
     }
 
     fun createEndPollEvent(
             roomId: String,
-            eventId: String
+            eventId: String,
+            additionalContent: Content? = null,
     ): Event {
         val content = MessageEndPollContent(
                 relatesTo = RelationDefaultContent(
@@ -240,7 +250,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 senderId = userId,
                 eventId = localId,
                 type = EventType.POLL_END.first(),
-                content = content.toContent(),
+                content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
     }
@@ -250,7 +260,8 @@ internal class LocalEchoEventFactory @Inject constructor(
             latitude: Double,
             longitude: Double,
             uncertainty: Double?,
-            isUserLocation: Boolean
+            isUserLocation: Boolean,
+            additionalContent: Content? = null,
     ): Event {
         val geoUri = buildGeoUri(latitude, longitude, uncertainty)
         val assetType = if (isUserLocation) LocationAssetType.SELF else LocationAssetType.PIN
@@ -262,7 +273,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 unstableTimestampMillis = clock.epochMillis(),
                 unstableText = geoUri
         )
-        return createMessageEvent(roomId, content)
+        return createMessageEvent(roomId, content, additionalContent)
     }
 
     fun createLiveLocationEvent(
@@ -270,7 +281,8 @@ internal class LocalEchoEventFactory @Inject constructor(
             roomId: String,
             latitude: Double,
             longitude: Double,
-            uncertainty: Double?
+            uncertainty: Double?,
+            additionalContent: Content? = null,
     ): Event {
         val geoUri = buildGeoUri(latitude, longitude, uncertainty)
         val content = MessageBeaconLocationDataContent(
@@ -289,7 +301,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 senderId = userId,
                 eventId = localId,
                 type = EventType.BEACON_LOCATION_DATA.first(),
-                content = content.toContent(),
+                content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
     }
@@ -301,7 +313,8 @@ internal class LocalEchoEventFactory @Inject constructor(
             newBodyText: String,
             autoMarkdown: Boolean,
             msgType: String,
-            compatibilityText: String
+            compatibilityText: String,
+            additionalContent: Content? = null,
     ): Event {
         val permalink = permalinkFactory.createPermalink(roomId, originalEvent.root.eventId ?: "", false)
         val userLink = originalEvent.root.senderId?.let { permalinkFactory.createPermalink(it, false) } ?: ""
@@ -336,25 +349,42 @@ internal class LocalEchoEventFactory @Inject constructor(
                                 formattedBody = replyFormatted
                         )
                                 .toContent()
-                )
+                ),
+                additionalContent,
         )
     }
 
     fun createMediaEvent(
             roomId: String,
             attachment: ContentAttachmentData,
-            rootThreadEventId: String?
+            rootThreadEventId: String?,
+            relatesTo: RelationDefaultContent?,
+            additionalContent: Content? = null,
     ): Event {
         return when (attachment.type) {
-            ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId)
-            ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId)
-            ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId)
-            ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true, rootThreadEventId = rootThreadEventId)
-            ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId)
+            ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId, relatesTo, additionalContent)
+            ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId, relatesTo, additionalContent)
+            ContentAttachmentData.Type.AUDIO -> createAudioEvent(
+                    roomId,
+                    attachment,
+                    isVoiceMessage = false,
+                    rootThreadEventId = rootThreadEventId,
+                    relatesTo,
+                    additionalContent
+            )
+            ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(
+                    roomId,
+                    attachment,
+                    isVoiceMessage = true,
+                    rootThreadEventId = rootThreadEventId,
+                    relatesTo,
+                    additionalContent,
+            )
+            ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId, relatesTo, additionalContent)
         }
     }
 
-    fun createReactionEvent(roomId: String, targetEventId: String, reaction: String): Event {
+    fun createReactionEvent(roomId: String, targetEventId: String, reaction: String, additionalContent: Content? = null): Event {
         val content = ReactionContent(
                 ReactionInfo(
                         RelationType.ANNOTATION,
@@ -369,12 +399,18 @@ internal class LocalEchoEventFactory @Inject constructor(
                 senderId = userId,
                 eventId = localId,
                 type = EventType.REACTION,
-                content = content.toContent(),
+                content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
     }
 
-    private fun createImageEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
+    private fun createImageEvent(
+            roomId: String,
+            attachment: ContentAttachmentData,
+            rootThreadEventId: String?,
+            relatesTo: RelationDefaultContent?,
+            additionalContent: Content?,
+    ): Event {
         var width = attachment.width
         var height = attachment.height
 
@@ -399,19 +435,18 @@ internal class LocalEchoEventFactory @Inject constructor(
                         size = attachment.size
                 ),
                 url = attachment.queryUri.toString(),
-                relatesTo = rootThreadEventId?.let {
-                    RelationDefaultContent(
-                            type = RelationType.THREAD,
-                            eventId = it,
-                            isFallingBack = true,
-                            inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
-                    )
-                }
+                relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
         )
-        return createMessageEvent(roomId, content)
+        return createMessageEvent(roomId, content, additionalContent)
     }
 
-    private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
+    private fun createVideoEvent(
+            roomId: String,
+            attachment: ContentAttachmentData,
+            rootThreadEventId: String?,
+            relatesTo: RelationDefaultContent?,
+            additionalContent: Content?,
+    ): Event {
         val mediaDataRetriever = MediaMetadataRetriever()
         mediaDataRetriever.setDataSource(context, attachment.queryUri)
 
@@ -443,23 +478,18 @@ internal class LocalEchoEventFactory @Inject constructor(
                         thumbnailInfo = thumbnailInfo
                 ),
                 url = attachment.queryUri.toString(),
-                relatesTo = rootThreadEventId?.let {
-                    RelationDefaultContent(
-                            type = RelationType.THREAD,
-                            eventId = it,
-                            isFallingBack = true,
-                            inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
-                    )
-                }
+                relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
         )
-        return createMessageEvent(roomId, content)
+        return createMessageEvent(roomId, content, additionalContent)
     }
 
     private fun createAudioEvent(
             roomId: String,
             attachment: ContentAttachmentData,
             isVoiceMessage: Boolean,
-            rootThreadEventId: String?
+            rootThreadEventId: String?,
+            relatesTo: RelationDefaultContent?,
+            additionalContent: Content?
     ): Event {
         val content = MessageAudioContent(
                 msgType = MessageType.MSGTYPE_AUDIO,
@@ -475,19 +505,18 @@ internal class LocalEchoEventFactory @Inject constructor(
                         waveform = waveformSanitizer.sanitize(attachment.waveform)
                 ),
                 voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(),
-                relatesTo = rootThreadEventId?.let {
-                    RelationDefaultContent(
-                            type = RelationType.THREAD,
-                            eventId = it,
-                            isFallingBack = true,
-                            inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
-                    )
-                }
+                relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
         )
-        return createMessageEvent(roomId, content)
+        return createMessageEvent(roomId, content, additionalContent)
     }
 
-    private fun createFileEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
+    private fun createFileEvent(
+            roomId: String,
+            attachment: ContentAttachmentData,
+            rootThreadEventId: String?,
+            relatesTo: RelationDefaultContent?,
+            additionalContent: Content?
+    ): Event {
         val content = MessageFileContent(
                 msgType = MessageType.MSGTYPE_FILE,
                 body = attachment.name ?: "file",
@@ -496,24 +525,18 @@ internal class LocalEchoEventFactory @Inject constructor(
                         size = attachment.size
                 ),
                 url = attachment.queryUri.toString(),
-                relatesTo = rootThreadEventId?.let {
-                    RelationDefaultContent(
-                            type = RelationType.THREAD,
-                            eventId = it,
-                            isFallingBack = true,
-                            inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
-                    )
-                }
+                relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
         )
-        return createMessageEvent(roomId, content)
+        return createMessageEvent(roomId, content, additionalContent)
     }
 
-    private fun createMessageEvent(roomId: String, content: MessageContent? = null): Event {
-        return createEvent(roomId, EventType.MESSAGE, content.toContent())
+    private fun createMessageEvent(roomId: String, content: MessageContent, additionalContent: Content?): Event {
+        return createEvent(roomId, EventType.MESSAGE, content.toContent(), additionalContent)
     }
 
-    fun createEvent(roomId: String, type: String, content: Content?): Event {
+    fun createEvent(roomId: String, type: String, content: Content?, additionalContent: Content? = null): Event {
         val newContent = enhanceStickerIfNeeded(type, content) ?: content
+        val updatedNewContent = newContent?.plus(additionalContent.orEmpty()) ?: additionalContent
         val localId = LocalEcho.createLocalEchoId()
         return Event(
                 roomId = roomId,
@@ -521,7 +544,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 senderId = userId,
                 eventId = localId,
                 type = type,
-                content = newContent,
+                content = updatedNewContent,
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
     }
@@ -555,7 +578,8 @@ internal class LocalEchoEventFactory @Inject constructor(
             text: CharSequence,
             msgType: String,
             autoMarkdown: Boolean,
-            formattedText: String?
+            formattedText: String?,
+            additionalContent: Content? = null,
     ): Event {
         val content = formattedText?.let { TextContent(text.toString(), it) } ?: createTextContent(text, autoMarkdown)
         return createEvent(
@@ -565,8 +589,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                         rootThreadEventId = rootThreadEventId,
                         latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId),
                         msgType = msgType
-                )
-                        .toContent()
+                ).toContent().plus(additionalContent.orEmpty())
         )
     }
 
@@ -581,9 +604,11 @@ internal class LocalEchoEventFactory @Inject constructor(
             roomId: String,
             eventReplied: TimelineEvent,
             replyText: CharSequence,
+            replyTextFormatted: CharSequence?,
             autoMarkdown: Boolean,
             rootThreadEventId: String? = null,
-            showInThread: Boolean
+            showInThread: Boolean,
+            additionalContent: Content? = null
     ): Event? {
         // Fallbacks and event representation
         // TODO Add error/warning logs when any of this is null
@@ -594,7 +619,7 @@ internal class LocalEchoEventFactory @Inject constructor(
         val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.isReply())
 
         // As we always supply formatted body for replies we should force the MarkdownParser to produce html.
-        val replyTextFormatted = markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted()
+        val finalReplyTextFormatted = replyTextFormatted?.toString() ?: markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted()
         // Body of the original message may not have formatted version, so may also have to convert to html.
         val bodyFormatted = body.formattedText ?: markdownParser.parse(body.text, force = true, advanced = autoMarkdown).takeFormatted()
         val replyFormatted = buildFormattedReply(
@@ -602,7 +627,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 userLink,
                 userId,
                 bodyFormatted,
-                replyTextFormatted
+                finalReplyTextFormatted
         )
         //
         // > <@alice:example.org> This is the original body
@@ -621,9 +646,17 @@ internal class LocalEchoEventFactory @Inject constructor(
                         showInThread = showInThread
                 )
         )
-        return createMessageEvent(roomId, content)
+        return createMessageEvent(roomId, content, additionalContent)
     }
 
+    private fun generateThreadRelationContent(rootThreadEventId: String) =
+            RelationDefaultContent(
+                    type = RelationType.THREAD,
+                    eventId = rootThreadEventId,
+                    isFallingBack = true,
+                    inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId)),
+            )
+
     /**
      * Generates the appropriate relatesTo object for a reply event.
      * It can either be a regular reply or a reply within a thread
@@ -742,7 +775,7 @@ internal class LocalEchoEventFactory @Inject constructor(
         }
     }
      */
-    fun createRedactEvent(roomId: String, eventId: String, reason: String?): Event {
+    fun createRedactEvent(roomId: String, eventId: String, reason: String?, additionalContent: Content? = null): Event {
         val localId = LocalEcho.createLocalEchoId()
         return Event(
                 roomId = roomId,
@@ -751,7 +784,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 eventId = localId,
                 type = EventType.REDACTION,
                 redacts = eventId,
-                content = reason?.let { mapOf("reason" to it).toContent() },
+                content = reason?.let { mapOf("reason" to it).toContent().plus(additionalContent.orEmpty()) } ?: additionalContent,
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
     }
@@ -765,29 +798,38 @@ internal class LocalEchoEventFactory @Inject constructor(
             roomId: String,
             quotedEvent: TimelineEvent,
             text: String,
+            formattedText: String?,
             autoMarkdown: Boolean,
-            rootThreadEventId: String?
+            rootThreadEventId: String?,
+            additionalContent: Content? = null,
     ): Event {
         val messageContent = quotedEvent.getLastMessageContent()
-        val textMsg = messageContent?.body
+        val textMsg = if (messageContent is MessageContentWithFormattedBody) {
+            messageContent.formattedBody
+        } else {
+            messageContent?.body
+        }
         val quoteText = legacyRiotQuoteText(textMsg, text)
+        val quoteFormattedText = "<blockquote>$textMsg</blockquote>$formattedText"
 
         return if (rootThreadEventId != null) {
             createMessageEvent(
                     roomId,
                     markdownParser
-                            .parse(quoteText, force = true, advanced = autoMarkdown)
+                            .parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText)
                             .toThreadTextContent(
                                     rootThreadEventId = rootThreadEventId,
                                     latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId),
                                     msgType = MessageType.MSGTYPE_TEXT
-                            )
+                            ),
+                    additionalContent,
             )
         } else {
             createFormattedTextEvent(
                     roomId,
-                    markdownParser.parse(quoteText, force = true, advanced = autoMarkdown),
-                    MessageType.MSGTYPE_TEXT
+                    markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText),
+                    MessageType.MSGTYPE_TEXT,
+                    additionalContent,
             )
         }
     }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
index 53c025387668a2cd3d5fee93e76959dd2969f0b1..b1a3d51b36a2e9070065f834691fbaf18ac1d54d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
@@ -96,4 +96,8 @@ internal class DefaultTimelineService @AssistedInject constructor(
     override fun getAttachmentMessages(): List<TimelineEvent> {
         return timelineEventDataSource.getAttachmentMessages(roomId)
     }
+
+    override fun getTimelineEventsRelatedTo(relationType: String, eventId: String): List<TimelineEvent> {
+        return timelineEventDataSource.getTimelineEventsRelatedTo(roomId, relationType, eventId)
+    }
 }
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 b1b9e4bb2280f5bca2451c9ed756eb286d99ea8d..20094e4be8a354b483740bc37176381739bca62e 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
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.timeline
 import androidx.lifecycle.LiveData
 import com.zhuinden.monarchy.Monarchy
 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.room.timeline.TimelineEvent
@@ -63,4 +64,18 @@ internal class TimelineEventDataSource @Inject constructor(
                     .orEmpty()
         }
     }
+
+    fun getTimelineEventsRelatedTo(roomId: String, eventType: String, eventId: String): List<TimelineEvent> {
+        // TODO Remove this trick and call relations API
+        // see https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1roomsroomidrelationseventidreltypeeventtype
+        return realmSessionProvider.withRealm { realm ->
+            TimelineEventEntity.whereRoomId(realm, roomId)
+                    .sort(TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS, Sort.ASCENDING)
+                    .distinct(TimelineEventEntityFields.EVENT_ID)
+                    .findAll()
+                    .mapNotNull {
+                        timelineEventMapper.map(it).takeIf { it.root.getRelationContent()?.takeIf { it.type == eventType && it.eventId == eventId } != null }
+                    }
+        }
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt
index 1f840a82d565fa4f76e8b0501783e4f1907f8d8e..1ee2fc4802c16193d6da2c0545bb848da5f4cbf2 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt
@@ -71,10 +71,16 @@ internal class UpdateUserWorker(context: Context, params: WorkerParameters, sess
                 ?.saveLocally()
     }
 
-    private suspend fun fetchUsers(userIdsToFetch: Collection<String>) = userIdsToFetch.mapNotNull {
-        tryOrNull {
-            val profileJson = getProfileInfoTask.execute(GetProfileInfoTask.Params(it))
-            User.fromJson(it, profileJson)
+    private suspend fun fetchUsers(userIdsToFetch: Collection<String>): List<User> {
+        return userIdsToFetch.mapNotNull { userId ->
+            tryOrNull {
+                val profileJson = getProfileInfoTask.execute(GetProfileInfoTask.Params(
+                        userId = userId,
+                        // Bulk insert later, so tell the task not to store the User.
+                        storeInDatabase = false,
+                ))
+                User.fromJson(userId, profileJson)
+            }
         }
     }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt
index f9feb04e97ae868fe4600cfd3e59311fcd786b28..98108008fe78cd0cc3a5d8a561617db83bcf06cd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt
@@ -66,6 +66,8 @@ internal class UserDataSource @Inject constructor(
         }
     }
 
+    fun getUserOrDefault(userId: String): User = getUser(userId) ?: User(userId)
+
     fun getUserLive(userId: String): LiveData<Optional<User>> {
         val liveData = monarchy.findAllMappedWithChanges(
                 { UserEntity.where(it, userId) },
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt
index df9dcfb903ec533efddbd1b77f93e73e043c7d4b..c73446cf25838472b936d71dcc7523b4d4780db6 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt
@@ -66,7 +66,7 @@ internal class DefaultSessionAccountDataService @Inject constructor(
 
     override suspend fun updateUserAccountData(type: String, content: Content) {
         val params = UpdateUserAccountDataTask.AnyParams(type = type, any = content)
-        awaitCallback<Unit> { callback ->
+        awaitCallback { callback ->
             updateUserAccountDataTask.configureWith(params) {
                 this.retryCount = 5 // TODO Need to refactor retrying out into a helper method.
                 this.callback = callback
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt
index 8bd61a7bdf5ffdb962bd7acb56e353e6861ef4e3..a43c59a83b9b96126c419f4ab1a33534cf95d6f7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt
@@ -20,7 +20,6 @@ import org.matrix.android.sdk.api.session.content.ContentUrlResolver
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.toModel
 import org.matrix.android.sdk.api.session.room.sender.SenderInfo
-import org.matrix.android.sdk.api.session.user.model.User
 import org.matrix.android.sdk.api.session.widgets.model.Widget
 import org.matrix.android.sdk.api.session.widgets.model.WidgetContent
 import org.matrix.android.sdk.api.session.widgets.model.WidgetType
@@ -74,7 +73,7 @@ internal class WidgetFactory @Inject constructor(
     // Ref: https://github.com/matrix-org/matrix-widget-api/blob/master/src/templating/url-template.ts#L29-L33
     fun computeURL(widget: Widget, isLightTheme: Boolean): String? {
         var computedUrl = widget.widgetContent.url ?: return null
-        val myUser = userDataSource.getUser(userId) ?: User(userId)
+        val myUser = userDataSource.getUserOrDefault(userId)
 
         val keyValue = widget.widgetContent.data.mapKeys { "\$${it.key}" }.toMutableMap()
 
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt
index a27f430edc55a7980c89b05467c5446ddf5d8747..8515427e8e3a74c6cae1246911e4d7bc6ce24f5d 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt
@@ -25,6 +25,7 @@ private const val A_DEVICE_ID = "device-id"
 private const val AN_IP_ADDRESS = "ip-address"
 private const val A_TIMESTAMP = 123L
 private const val A_DISPLAY_NAME = "display-name"
+private const val A_USER_AGENT = "user-agent"
 
 class MyDeviceLastSeenInfoEntityMapperTest {
 
@@ -32,21 +33,55 @@ class MyDeviceLastSeenInfoEntityMapperTest {
 
     @Test
     fun `given an entity when mapping to model then all fields are correctly mapped`() {
+        // Given
         val entity = MyDeviceLastSeenInfoEntity(
                 deviceId = A_DEVICE_ID,
                 lastSeenIp = AN_IP_ADDRESS,
                 lastSeenTs = A_TIMESTAMP,
-                displayName = A_DISPLAY_NAME
+                displayName = A_DISPLAY_NAME,
+                lastSeenUserAgent = A_USER_AGENT,
         )
         val expectedDeviceInfo = DeviceInfo(
                 deviceId = A_DEVICE_ID,
                 lastSeenIp = AN_IP_ADDRESS,
                 lastSeenTs = A_TIMESTAMP,
-                displayName = A_DISPLAY_NAME
+                displayName = A_DISPLAY_NAME,
+                unstableLastSeenUserAgent = A_USER_AGENT,
         )
 
+        // When
         val deviceInfo = myDeviceLastSeenInfoEntityMapper.map(entity)
 
+        // Then
         deviceInfo shouldBeEqualTo expectedDeviceInfo
     }
+
+    @Test
+    fun `given a device info when mapping to entity then all fields are correctly mapped`() {
+        // Given
+        val deviceInfo = DeviceInfo(
+                deviceId = A_DEVICE_ID,
+                lastSeenIp = AN_IP_ADDRESS,
+                lastSeenTs = A_TIMESTAMP,
+                displayName = A_DISPLAY_NAME,
+                unstableLastSeenUserAgent = A_USER_AGENT,
+        )
+        val expectedEntity = MyDeviceLastSeenInfoEntity(
+                deviceId = A_DEVICE_ID,
+                lastSeenIp = AN_IP_ADDRESS,
+                lastSeenTs = A_TIMESTAMP,
+                displayName = A_DISPLAY_NAME,
+                lastSeenUserAgent = A_USER_AGENT
+        )
+
+        // When
+        val entity = myDeviceLastSeenInfoEntityMapper.map(deviceInfo)
+
+        // Then
+        entity.deviceId shouldBeEqualTo expectedEntity.deviceId
+        entity.lastSeenIp shouldBeEqualTo expectedEntity.lastSeenIp
+        entity.lastSeenTs shouldBeEqualTo expectedEntity.lastSeenTs
+        entity.displayName shouldBeEqualTo expectedEntity.displayName
+        entity.lastSeenUserAgent shouldBeEqualTo expectedEntity.lastSeenUserAgent
+    }
 }
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/PushersMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/PushersMapperTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..08ed20a7660fff3223cbbf98947154dc7824236f
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/PushersMapperTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.database.mapper
+
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.Test
+import org.matrix.android.sdk.test.fixtures.JsonPusherFixture.aJsonPusher
+import org.matrix.android.sdk.test.fixtures.PusherEntityFixture.aPusherEntity
+
+class PushersMapperTest {
+
+    @Test
+    fun `when mapping PusherEntity, then it is mapped into Pusher successfully`() {
+        val pusherEntity = aPusherEntity()
+
+        val mappedPusher = PushersMapper.map(pusherEntity)
+
+        mappedPusher.pushKey shouldBeEqualTo pusherEntity.pushKey
+        mappedPusher.kind shouldBeEqualTo pusherEntity.kind.orEmpty()
+        mappedPusher.appId shouldBeEqualTo pusherEntity.appId
+        mappedPusher.appDisplayName shouldBeEqualTo pusherEntity.appDisplayName
+        mappedPusher.deviceDisplayName shouldBeEqualTo pusherEntity.deviceDisplayName
+        mappedPusher.profileTag shouldBeEqualTo pusherEntity.profileTag
+        mappedPusher.lang shouldBeEqualTo pusherEntity.lang
+        mappedPusher.data.url shouldBeEqualTo pusherEntity.data?.url
+        mappedPusher.data.format shouldBeEqualTo pusherEntity.data?.format
+        mappedPusher.enabled shouldBeEqualTo pusherEntity.enabled
+        mappedPusher.deviceId shouldBeEqualTo pusherEntity.deviceId
+        mappedPusher.state shouldBeEqualTo pusherEntity.state
+    }
+
+    @Test
+    fun `when mapping JsonPusher, then it is mapped into Pusher successfully`() {
+        val jsonPusher = aJsonPusher()
+
+        val mappedPusherEntity = PushersMapper.map(jsonPusher)
+
+        mappedPusherEntity.pushKey shouldBeEqualTo jsonPusher.pushKey
+        mappedPusherEntity.kind shouldBeEqualTo jsonPusher.kind
+        mappedPusherEntity.appId shouldBeEqualTo jsonPusher.appId
+        mappedPusherEntity.appDisplayName shouldBeEqualTo jsonPusher.appDisplayName
+        mappedPusherEntity.deviceDisplayName shouldBeEqualTo jsonPusher.deviceDisplayName
+        mappedPusherEntity.profileTag shouldBeEqualTo jsonPusher.profileTag
+        mappedPusherEntity.lang shouldBeEqualTo jsonPusher.lang
+        mappedPusherEntity.data?.url shouldBeEqualTo jsonPusher.data?.url
+        mappedPusherEntity.data?.format shouldBeEqualTo jsonPusher.data?.format
+        mappedPusherEntity.enabled shouldBeEqualTo jsonPusher.enabled
+        mappedPusherEntity.deviceId shouldBeEqualTo jsonPusher.deviceId
+    }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCaseTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCaseTest.kt
index 9ed6f28d7e22cd5bae3436db318fff951b0ccacc..2170371cdecd20cb961957cee3a640bd48eee1df 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCaseTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/network/ComputeUserAgentUseCaseTest.kt
@@ -27,6 +27,8 @@ import org.amshove.kluent.shouldBeEqualTo
 import org.junit.Before
 import org.junit.Test
 import org.matrix.android.sdk.BuildConfig
+import org.matrix.android.sdk.api.util.getApplicationInfoCompat
+import org.matrix.android.sdk.api.util.getPackageInfoCompat
 import java.lang.Exception
 
 private const val A_PACKAGE_NAME = "org.matrix.sdk"
@@ -49,8 +51,8 @@ class ComputeUserAgentUseCaseTest {
         every { context.applicationContext } returns context
         every { context.packageName } returns A_PACKAGE_NAME
         every { context.packageManager } returns packageManager
-        every { packageManager.getApplicationInfo(any(), any()) } returns applicationInfo
-        every { packageManager.getPackageInfo(any<String>(), any()) } returns packageInfo
+        every { packageManager.getApplicationInfoCompat(any(), any()) } returns applicationInfo
+        every { packageManager.getPackageInfoCompat(any(), any()) } returns packageInfo
     }
 
     @Test
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt
index dac33069f348732ec667531b993025c5e1ebbad6..a971973f5664617205adba432fcbb286bf50b950 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt
@@ -71,7 +71,7 @@ class DefaultAddPusherTaskTest {
     }
 
     @Test
-    fun `given a persisted pusher when adding Pusher then updates api and mutates persisted result with Registered state`() {
+    fun `given a persisted pusher, when adding Pusher, then updates api and mutates persisted result with Registered state`() {
         val realmResult = PusherEntity(appDisplayName = null)
         monarchy.givenWhereReturns(result = realmResult)
                 .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey)
@@ -85,7 +85,7 @@ class DefaultAddPusherTaskTest {
     }
 
     @Test
-    fun `given a persisted push entity and SetPush API fails when adding Pusher then mutates persisted result with Failed registration state and rethrows`() {
+    fun `given a persisted push entity and SetPush API fails, when adding Pusher, then mutates persisted result with Failed registration state and rethrows`() {
         val realmResult = PusherEntity()
         monarchy.givenWhereReturns(result = realmResult)
                 .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey)
@@ -99,7 +99,7 @@ class DefaultAddPusherTaskTest {
     }
 
     @Test
-    fun `given no persisted push entity and SetPush API fails when adding Pusher then rethrows error`() {
+    fun `given no persisted push entity and SetPush API fails, when adding Pusher, then rethrows error`() {
         monarchy.givenWhereReturns<PusherEntity>(result = null)
                 .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey)
         pushersAPI.givenSetPusherErrors(SocketException())
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersServiceTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersServiceTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a00ac3a17d5fc7b6d4336c1666afba886671e9fe
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersServiceTest.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.session.pushers
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.matrix.android.sdk.test.fakes.FakeAddPusherTask
+import org.matrix.android.sdk.test.fakes.FakeGetPushersTask
+import org.matrix.android.sdk.test.fakes.FakeMonarchy
+import org.matrix.android.sdk.test.fakes.FakeRemovePusherTask
+import org.matrix.android.sdk.test.fakes.FakeTaskExecutor
+import org.matrix.android.sdk.test.fakes.FakeTogglePusherTask
+import org.matrix.android.sdk.test.fakes.FakeWorkManagerProvider
+import org.matrix.android.sdk.test.fakes.internal.FakePushGatewayNotifyTask
+import org.matrix.android.sdk.test.fixtures.PusherFixture
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DefaultPushersServiceTest {
+
+    private val workManagerProvider = FakeWorkManagerProvider()
+    private val monarchy = FakeMonarchy()
+    private val sessionId = ""
+    private val getPushersTask = FakeGetPushersTask()
+    private val pushGatewayNotifyTask = FakePushGatewayNotifyTask()
+    private val addPusherTask = FakeAddPusherTask()
+    private val togglePusherTask = FakeTogglePusherTask()
+    private val removePusherTask = FakeRemovePusherTask()
+    private val taskExecutor = FakeTaskExecutor()
+
+    private val pushersService = DefaultPushersService(
+            workManagerProvider.instance,
+            monarchy.instance,
+            sessionId,
+            getPushersTask,
+            pushGatewayNotifyTask,
+            addPusherTask,
+            togglePusherTask,
+            removePusherTask,
+            taskExecutor.instance,
+    )
+
+    @Test
+    fun `when togglePusher, then execute task`() = runTest {
+        val pusher = PusherFixture.aPusher()
+        val enable = true
+
+        pushersService.togglePusher(pusher, enable)
+
+        togglePusherTask.verifyExecution(pusher, enable)
+    }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultTogglePusherTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultTogglePusherTaskTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3c54f6f1e12e00e3fffaf1e24c11e3fb64e9c265
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultTogglePusherTaskTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.session.pushers
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.Test
+import org.matrix.android.sdk.internal.database.model.PusherEntity
+import org.matrix.android.sdk.internal.database.model.PusherEntityFields
+import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver
+import org.matrix.android.sdk.test.fakes.FakeMonarchy
+import org.matrix.android.sdk.test.fakes.FakePushersAPI
+import org.matrix.android.sdk.test.fakes.FakeRequestExecutor
+import org.matrix.android.sdk.test.fakes.givenEqualTo
+import org.matrix.android.sdk.test.fakes.givenFindFirst
+import org.matrix.android.sdk.test.fixtures.JsonPusherFixture.aJsonPusher
+import org.matrix.android.sdk.test.fixtures.PusherEntityFixture.aPusherEntity
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DefaultTogglePusherTaskTest {
+
+    private val pushersAPI = FakePushersAPI()
+    private val monarchy = FakeMonarchy()
+    private val requestExecutor = FakeRequestExecutor()
+    private val globalErrorReceiver = FakeGlobalErrorReceiver()
+
+    private val togglePusherTask = DefaultTogglePusherTask(pushersAPI, monarchy.instance, requestExecutor, globalErrorReceiver)
+
+    @Test
+    fun `execution toggles enable on both local and remote`() = runTest {
+        val jsonPusher = aJsonPusher(enabled = false)
+        val params = TogglePusherTask.Params(aJsonPusher(), true)
+
+        val pusherEntity = aPusherEntity(enabled = false)
+        monarchy.givenWhere<PusherEntity>()
+                .givenEqualTo(PusherEntityFields.PUSH_KEY, jsonPusher.pushKey)
+                .givenFindFirst(pusherEntity)
+
+        togglePusherTask.execute(params)
+
+        val expectedPayload = jsonPusher.copy(enabled = true)
+        pushersAPI.verifySetPusher(expectedPayload)
+        monarchy.verifyInsertOrUpdate<PusherEntity> {
+            withArg { actual ->
+                actual.enabled shouldBeEqualTo true
+            }
+        }
+    }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAddPusherTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAddPusherTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..16cdd7a62645f6a04448ce2b883189723105d262
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAddPusherTask.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.test.fakes
+
+import io.mockk.mockk
+import org.matrix.android.sdk.internal.session.pushers.AddPusherTask
+
+class FakeAddPusherTask : AddPusherTask by mockk()
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetPushersTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetPushersTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d5a41bb0e002bd49df922b1f7baf5a0828d3ca82
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetPushersTask.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.test.fakes
+
+import io.mockk.mockk
+import org.matrix.android.sdk.internal.session.pushers.GetPushersTask
+
+class FakeGetPushersTask : GetPushersTask by mockk()
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRemovePusherTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRemovePusherTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..55a7607a032706abe213494e72392e5f8cd7043f
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRemovePusherTask.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.test.fakes
+
+import io.mockk.mockk
+import org.matrix.android.sdk.internal.session.pushers.RemovePusherTask
+
+class FakeRemovePusherTask : RemovePusherTask by mockk()
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTaskExecutor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTaskExecutor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..543dda8a4f93decc3233cf676451e95d97b76c67
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTaskExecutor.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.test.fakes
+
+import io.mockk.mockk
+import org.matrix.android.sdk.internal.task.TaskExecutor
+
+internal class FakeTaskExecutor {
+
+    val instance: TaskExecutor = mockk()
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTogglePusherTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTogglePusherTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b1e059a40ed20ff9605b9c60bb350a3af438b508
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTogglePusherTask.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.test.fakes
+
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import org.amshove.kluent.shouldBeEqualTo
+import org.matrix.android.sdk.api.session.pushers.Pusher
+import org.matrix.android.sdk.internal.session.pushers.TogglePusherTask
+
+class FakeTogglePusherTask : TogglePusherTask by mockk(relaxed = true) {
+
+    fun verifyExecution(pusher: Pusher, enable: Boolean) {
+        val slot = slot<TogglePusherTask.Params>()
+        coVerify { execute(capture(slot)) }
+        val params = slot.captured
+        params.pusher.pushKey shouldBeEqualTo pusher.pushKey
+        params.enable shouldBeEqualTo enable
+    }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePushGatewayNotifyTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePushGatewayNotifyTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..46a106dcb26d4a732b2810c393c832cbd38eabcb
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePushGatewayNotifyTask.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.test.fakes.internal
+
+import io.mockk.mockk
+import org.matrix.android.sdk.internal.session.pushers.gateway.PushGatewayNotifyTask
+
+class FakePushGatewayNotifyTask : PushGatewayNotifyTask by mockk()
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/JsonPusherFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/JsonPusherFixture.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8e679ff91c0294dd19e69e34637aa0e5596dedb6
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/JsonPusherFixture.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.test.fixtures
+
+import org.matrix.android.sdk.internal.session.pushers.JsonPusher
+import org.matrix.android.sdk.internal.session.pushers.JsonPusherData
+
+internal object JsonPusherFixture {
+
+    fun aJsonPusher(
+            pushKey: String = "",
+            kind: String? = null,
+            appId: String = "",
+            appDisplayName: String? = null,
+            deviceDisplayName: String? = null,
+            profileTag: String? = null,
+            lang: String? = null,
+            data: JsonPusherData? = null,
+            append: Boolean? = false,
+            enabled: Boolean = true,
+            deviceId: String? = null,
+    ) = JsonPusher(
+            pushKey,
+            kind,
+            appId,
+            appDisplayName,
+            deviceDisplayName,
+            profileTag,
+            lang,
+            data,
+            append,
+            enabled,
+            deviceId,
+    )
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherEntityFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherEntityFixture.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8d048d4c9ab432e0c04bc0a5527fff89f01c3a5b
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherEntityFixture.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.test.fixtures
+
+import org.matrix.android.sdk.internal.database.model.PusherDataEntity
+import org.matrix.android.sdk.internal.database.model.PusherEntity
+
+internal object PusherEntityFixture {
+
+    fun aPusherEntity(
+            pushKey: String = "",
+            kind: String? = null,
+            appId: String = "",
+            appDisplayName: String? = null,
+            deviceDisplayName: String? = null,
+            profileTag: String? = null,
+            lang: String? = null,
+            data: PusherDataEntity? = null,
+            enabled: Boolean = true,
+            deviceId: String? = null,
+    ) = PusherEntity(
+            pushKey,
+            kind,
+            appId,
+            appDisplayName,
+            deviceDisplayName,
+            profileTag,
+            lang,
+            data,
+            enabled,
+            deviceId,
+    )
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0ac7885062bce6134bf503da0409680dc25325f8
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.test.fixtures
+
+import org.matrix.android.sdk.api.session.pushers.Pusher
+import org.matrix.android.sdk.api.session.pushers.PusherData
+import org.matrix.android.sdk.api.session.pushers.PusherState
+
+object PusherFixture {
+
+    fun aPusher(
+            pushKey: String = "",
+            kind: String = "",
+            appId: String = "",
+            appDisplayName: String? = "",
+            deviceDisplayName: String? = "",
+            profileTag: String? = null,
+            lang: String? = "",
+            data: PusherData = PusherData("f.o/_matrix/push/v1/notify", ""),
+            enabled: Boolean = true,
+            deviceId: String? = "",
+            state: PusherState = PusherState.REGISTERED,
+    ) = Pusher(
+            pushKey,
+            kind,
+            appId,
+            appDisplayName,
+            deviceDisplayName,
+            profileTag,
+            lang,
+            data,
+            enabled,
+            deviceId,
+            state,
+    )
+}