diff --git a/CHANGES.md b/CHANGES.md index 7b52a037..dfda3cef 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,24 +1,34 @@ -Changes in RiotX 0.XX (2019-XX-XX) +Changes in RiotX 0.2.0 (2019-07-18) =================================================== Features: - - Contextual action menu for messages in room + - Message Editing: View edit history (#121) + - Rooms filtering (#304) + - Edit in encrypted room Improvements: - - + - Handle click on redacted events: view source and create permalink + - Improve long tap menu: reply on top, more compact (#368) + - Quick reply in timeline with swipe gesture (#167) + - Improve edit of replies + - Improve performance on Room Members and Users management (#381) Other changes: - - + - migrate from rxbinding 2 to rxbinding 3 Bugfix: - - + - Fix regression on permalink click + - Fix crash reported by the PlayStore (#341) + - Fix Chat composer separator color in dark/black theme + - Fix bad layout for room directory filter (#349) + - Fix Copying link from a message shouldn't open context menu (#364) -Translations: - - +Changes in RiotX 0.1.0 (2019-07-11) +=================================================== -Build: - - +First release! +Mode details here: https://medium.com/@RiotChat/introducing-the-riotx-beta-for-android-b17952e8f771 ======================================================= diff --git a/README.md b/README.md index 2fe92f58..d9f94454 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Buildkite](https://badge.buildkite.com/657d3db27364448d69d54f66c690f7788bc6aa80a7628e37f3.svg?branch=develop)](https://buildkite.com/matrix-dot-org/riotx-android) +[![Buildkite](https://badge.buildkite.com/657d3db27364448d69d54f66c690f7788bc6aa80a7628e37f3.svg?branch=develop)](https://buildkite.com/matrix-dot-org/riotx-android/builds?branch=develop) [![Weblate](https://translate.riot.im/widgets/riot-android/-/svg-badge.svg)](https://translate.riot.im/engage/riot-android/?utm_source=widget) [![RiotX Android Matrix room #riot-android:matrix.org](https://img.shields.io/matrix/riotx:matrix.org.svg?label=%23RiotX:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#riotx:matrix.org) [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=vector.android.riotx&metric=alert_status)](https://sonarcloud.io/dashboard?id=vector.android.riotx) @@ -7,14 +7,31 @@ # RiotX Android -RiotX is an Android Matrix Client currently in development. The application is not yet available on the PlayStore. +RiotX is an Android Matrix Client currently in beta but in active development. -It's based on a new Matrix SDK, written in Kotlin. +It is a total rewrite of [Riot-Android](https://github.com/vector-im/riot-android) with a new user experience. RiotX will become the official replacement as soon as all features are implemented. -Download nightly build here: [![Buildkite](https://badge.buildkite.com/657d3db27364448d69d54f66c690f7788bc6aa80a7628e37f3.svg?branch=develop)](https://buildkite.com/matrix-dot-org/riotx-android/builds?branch=develop) +[Get it on Google Play](https://play.google.com/store/apps/details?id=im.vector.riotx) + +Nightly build: [![Buildkite](https://badge.buildkite.com/657d3db27364448d69d54f66c690f7788bc6aa80a7628e37f3.svg?branch=develop)](https://buildkite.com/matrix-dot-org/riotx-android/builds?branch=develop) + +# New Android SDK + +RiotX is based on a new Android SDK fully written in Kotlin (like RiotX). In order to make the early development as fast as possible, RiotX and the new SDK currently share the same git repository. We will make separate repos once the API is stable enough. + + +# Roadmap + +The current target is to release an application out of beta with the same level of features (and even more) as Riot. +The roadmap has 3 phases: + +- [phase 0](https://github.com/vector-im/riotX-android/labels/phase0): Prototyping / Project setup +- [phase 1](https://github.com/vector-im/riotX-android/labels/phase1): Beta release to the Play Store +- [phase 2](https://github.com/vector-im/riotX-android/labels/phase2): Out of beta -Matrix Room: [![RiotX Android Matrix room #riot-android:matrix.org](https://img.shields.io/matrix/riotx:matrix.org.svg?label=%23RiotX:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#riotx:matrix.org) ## Contributing -Please refer to [CONTRIBUTING.md](https://github.com/vector-im/riotX-android/blob/develop/CONTRIBUTING.md) if you want to contribute the Matrix on Android projects! +Please refer to [CONTRIBUTING.md](https://github.com/vector-im/riotX-android/blob/develop/CONTRIBUTING.md) if you want to contribute on Matrix Android projects! + +Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/#riotx:matrix.org). diff --git a/build.gradle b/build.gradle index 91415088..b52707d5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,5 @@ +import javax.tools.JavaCompiler + // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { @@ -45,7 +47,26 @@ allprojects { maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } google() jcenter() + maven { + url 'https://repo.adobe.com/nexus/content/repositories/public/' + content { + includeGroupByRegex "diff_match_patch" + } + } } + + tasks.withType(JavaCompile).all { + options.compilerArgs += [ + '-Adagger.gradle.incremental=enabled' + ] + } + + afterEvaluate { + extensions.findByName("kapt")?.arguments { + arg("dagger.gradle.incremental", "enabled") + } + } + } task clean(type: Delete) { diff --git a/docs/notifications.md b/docs/notifications.md index 290a63a6..328eb869 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -1,4 +1,4 @@ -This document aims to describe how Riot X android displays notifications to the end user. It also clarifies notifications and background settings in the app. +This document aims to describe how RiotX android displays notifications to the end user. It also clarifies notifications and background settings in the app. # Table of Contents 1. [Prerequisites Knowledge](#prerequisites-knowledge) @@ -50,7 +50,7 @@ By default, this is 0, so the server will return immediately even if the respons **delay** is a client preference. When the server responds to a sync request, the client waits for `delay`before calling a new sync. -When the Riot X Android app is open (i.e in foreground state), the default timeout is 30 seconds, and delay is 0. +When the RiotX Android app is open (i.e in foreground state), the default timeout is 30 seconds, and delay is 0. ## How does a mobile app receives push notification @@ -86,7 +86,7 @@ This need some disambiguation, because it is the source of common confusion: In order to send a push to a mobile, App developers need to have a server that will use the FCM APIs, and these APIs requires authentication! This server is called a **Push Gateway** in the matrix world -That means that Riot X Android, a matrix client created by New Vector, is using a **Push Gateway** with the needed credentials (FCM API secret Key) in order to send push to the New Vector client. +That means that RiotX Android, a matrix client created by New Vector, is using a **Push Gateway** with the needed credentials (FCM API secret Key) in order to send push to the New Vector client. If you create your own matrix client, you will also need to deploy an instance of a **Push Gateway** with the credentials needed to use FCM for your app. @@ -223,7 +223,7 @@ Upon reception of the FCM push, RiotX will perform a sync call to the Home Serve * The sync generates additional notifications (e.g an encrypted message where the user is mentioned detected locally) * The sync takes too long and the process is killed before completion, or network is not reliable and the sync fails. -Riot X implements several strategies in these cases (TODO document) +RiotX implements several strategies in these cases (TODO document) ## FCM Fallback mode diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 3eaf43eb..b62b3fea 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -99,14 +99,14 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" - implementation "androidx.appcompat:appcompat:1.1.0-beta01" - implementation "androidx.recyclerview:recyclerview:1.1.0-alpha06" + implementation "androidx.appcompat:appcompat:1.1.0-rc01" + implementation "androidx.recyclerview:recyclerview:1.1.0-beta01" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" // Network - implementation 'com.squareup.retrofit2:retrofit:2.4.0' + implementation 'com.squareup.retrofit2:retrofit:2.6.0' implementation 'com.squareup.retrofit2:converter-moshi:2.4.0' implementation 'com.squareup.okhttp3:okhttp:3.14.1' implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0' diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt index cfe5a051..fb3dbcc2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/MatrixLinkify.kt @@ -42,7 +42,7 @@ object MatrixLinkify { hasMatch = true val startPos = match.range.first if (startPos == 0 || text[startPos - 1] != '/') { - val endPos = match.range.last + val endPos = match.range.last + 1 val url = text.substring(match.range) val span = MatrixPermalinkSpan(url, callback) spannable.setSpan(span, startPos, endPos, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) @@ -51,5 +51,5 @@ object MatrixLinkify { } return hasMatch } - + } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt index 08e706c3..2dde175b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt @@ -57,6 +57,9 @@ interface Session : */ val sessionParams: SessionParams + /** + * Useful shortcut to get access to the userId + */ val myUserId: String get() = sessionParams.credentials.userId @@ -84,7 +87,7 @@ interface Session : /** * This method start the sync thread. */ - fun startSync() + fun startSync(fromForeground : Boolean) /** * This method stop the sync thread. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt index 56d4801c..0f5421a0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt @@ -17,7 +17,7 @@ package im.vector.matrix.android.api.session.events.model /** - * Constants defining known event relation types from Matrix specifications. + * Constants defining known event relation types from Matrix specifications */ object RelationType { @@ -25,7 +25,7 @@ object RelationType { const val ANNOTATION = "m.annotation" /** Lets you define an event which replaces an existing event.*/ const val REPLACE = "m.replace" - /** ets you define an event which references an existing event.*/ + /** Lets you define an event which references an existing event.*/ const val REFERENCE = "m.reference" } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt index c45e47fc..bd32a75a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt @@ -25,4 +25,9 @@ interface MessageContent { val body: String val relatesTo: RelationDefaultContent? val newContent: Content? +} + + +fun MessageContent?.isReply(): Boolean { + return this?.relatesTo?.inReplyTo != null } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationContent.kt index 9bbdf5ab..5f89a482 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationContent.kt @@ -16,7 +16,10 @@ package im.vector.matrix.android.api.session.room.model.relation +import im.vector.matrix.android.api.session.events.model.RelationType + interface RelationContent { + /** See [RelationType] for known possible values */ val type: String? val eventId: String? val inReplyTo: ReplyToContent? diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt index 81d7ddd4..0c4e1beb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt @@ -16,6 +16,8 @@ package im.vector.matrix.android.api.session.room.model.relation import androidx.lifecycle.LiveData +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.util.Cancelable @@ -79,6 +81,25 @@ interface RelationService { compatibilityBodyText: String = "* $newBodyText"): Cancelable + /** + * Edit a reply. This is a special case because replies contains fallback text as a prefix. + * 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 compatibilityBodyText The text that will appear on clients that don't support yet edition + */ + fun editReply(replyToEdit: TimelineEvent, + originalTimelineEvent: TimelineEvent, + newBodyText: String, + compatibilityBodyText: String = "* $newBodyText"): Cancelable + + /** + * Get's the edit history of the given event + */ + fun fetchEditHistory(eventId: String, callback: MatrixCallback>) + + /** * Reply to an event in the timeline (must be in same room) * https://matrix.org/docs/spec/client_server/r0.4.0.html#id350 @@ -91,4 +112,6 @@ interface RelationService { autoMarkdown: Boolean = false): Cancelable? fun getEventSummaryLive(eventId: String): LiveData + + } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt index f626e3a7..044aa957 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt @@ -21,7 +21,10 @@ import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.model.message.isReply import im.vector.matrix.android.api.session.room.send.SendState +import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply +import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent /** * This data class is a wrapper around an Event. It allows to get useful data in the context of a timeline. @@ -88,3 +91,15 @@ data class TimelineEvent( */ fun TimelineEvent.getLastMessageContent(): MessageContent? = annotations?.editSummary?.aggregatedContent?.toModel() ?: root.getClearContent().toModel() + + +fun TimelineEvent.getTextEditableContent(): String? { + val originalContent = root.getClearContent().toModel() ?: return null + val isReply = originalContent.isReply() || root.content.toModel()?.relatesTo?.inReplyTo?.eventId != null + val lastContent = getLastMessageContent() + return if (isReply) { + return extractUsefulTextFromReply(lastContent?.body ?: "") + } else { + lastContent?.body ?: "" + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt index a2af70f4..cc1c3f1a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt @@ -18,7 +18,7 @@ package im.vector.matrix.android.api.session.sync sealed class SyncState { object IDLE : SyncState() - data class RUNNING(val catchingUp: Boolean) : SyncState() + data class RUNNING(val afterPause: Boolean) : SyncState() object PAUSED : SyncState() object KILLING : SyncState() object KILLED : SyncState() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/ContentUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/ContentUtils.kt new file mode 100644 index 00000000..ad17d26b --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/ContentUtils.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.matrix.android.api.util + + +object ContentUtils { + fun extractUsefulTextFromReply(repliedBody: String): String { + val lines = repliedBody.lines() + var wellFormed = repliedBody.startsWith(">") + var endOfPreviousFound = false + val usefullines = ArrayList() + lines.forEach { + if (it == "") { + endOfPreviousFound = true + return@forEach + } + if (!endOfPreviousFound) { + wellFormed = wellFormed && it.startsWith(">") + } else { + usefullines.add(it) + } + } + return usefullines.joinToString("\n").takeIf { wellFormed } ?: repliedBody + } + + fun extractUsefulTextFromHtmlReply(repliedBody: String): String { + if (repliedBody.startsWith("")) { + val closingTagIndex = repliedBody.lastIndexOf("") + if (closingTagIndex != -1) + return repliedBody.substring(closingTagIndex + "".length).trim() + } + return repliedBody + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/SessionManager.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/SessionManager.kt index 7cc73ceb..21f16d3d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/SessionManager.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/SessionManager.kt @@ -50,16 +50,10 @@ internal class SessionManager @Inject constructor(private val matrixComponent: M } private fun getOrCreateSessionComponent(sessionParams: SessionParams): SessionComponent { - val userId = sessionParams.credentials.userId - if (sessionComponents.containsKey(userId)) { - return sessionComponents[userId]!! + return sessionComponents.getOrPut(sessionParams.credentials.userId) { + DaggerSessionComponent + .factory() + .create(matrixComponent, sessionParams) } - return DaggerSessionComponent - .factory() - .create(matrixComponent, sessionParams) - .also { - sessionComponents[sessionParams.credentials.userId] = it - } } - } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt index d52a457c..3fadb09b 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt @@ -21,7 +21,6 @@ package im.vector.matrix.android.internal.crypto import android.content.Context import android.os.Handler import android.os.Looper -import android.text.TextUtils import arrow.core.Try import com.squareup.moshi.Types import com.zhuinden.monarchy.Monarchy @@ -80,10 +79,9 @@ import im.vector.matrix.android.internal.util.fetchCopied import kotlinx.coroutines.* import org.matrix.olm.OlmManager import timber.log.Timber -import java.util.* import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlin.coroutines.EmptyCoroutineContext +import kotlin.math.max /** * A `CryptoService` class instance manages the end-to-end crypto for a session. @@ -248,7 +246,7 @@ internal class CryptoManager @Inject constructor( return } isStarting.set(true) - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { internalStart(isInitialSync) } } @@ -315,7 +313,7 @@ internal class CryptoManager @Inject constructor( * @param syncResponse the syncResponse */ fun onSyncCompleted(syncResponse: SyncResponse) { - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { if (syncResponse.deviceLists != null) { deviceListManager.handleDeviceListsChanges(syncResponse.deviceLists.changed, syncResponse.deviceLists.left) } @@ -340,7 +338,7 @@ internal class CryptoManager @Inject constructor( * @return the device info, or null if not found / unsupported algorithm / crypto released */ override fun deviceWithIdentityKey(senderKey: String, algorithm: String): MXDeviceInfo? { - return if (!TextUtils.equals(algorithm, MXCRYPTO_ALGORITHM_MEGOLM) && !TextUtils.equals(algorithm, MXCRYPTO_ALGORITHM_OLM)) { + return if (algorithm != MXCRYPTO_ALGORITHM_MEGOLM && algorithm != MXCRYPTO_ALGORITHM_OLM) { // We only deal in olm keys null } else cryptoStore.deviceWithIdentityKey(senderKey) @@ -353,8 +351,8 @@ internal class CryptoManager @Inject constructor( * @param deviceId the device id */ override fun getDeviceInfo(userId: String, deviceId: String?): MXDeviceInfo? { - return if (!TextUtils.isEmpty(userId) && !TextUtils.isEmpty(deviceId)) { - cryptoStore.getUserDevice(deviceId!!, userId) + return if (userId.isNotEmpty() && !deviceId.isNullOrEmpty()) { + cryptoStore.getUserDevice(deviceId, userId) } else { null } @@ -439,7 +437,7 @@ internal class CryptoManager @Inject constructor( // (for now at least. Maybe we should alert the user somehow?) val existingAlgorithm = cryptoStore.getRoomAlgorithm(roomId) - if (!TextUtils.isEmpty(existingAlgorithm) && !TextUtils.equals(existingAlgorithm, algorithm)) { + if (!existingAlgorithm.isNullOrEmpty() && existingAlgorithm != algorithm) { Timber.e("## setEncryptionInRoom() : Ignoring m.room.encryption event which requests a change of config in $roomId") return false } @@ -535,7 +533,7 @@ internal class CryptoManager @Inject constructor( eventType: String, roomId: String, callback: MatrixCallback) { - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { if (!isStarted()) { Timber.v("## encryptEventContent() : wait after e2e init") internalStart(false) @@ -601,7 +599,7 @@ internal class CryptoManager @Inject constructor( * @param callback the callback to return data or null */ override fun decryptEventAsync(event: Event, timeline: String, callback: MatrixCallback) { - GlobalScope.launch(EmptyCoroutineContext) { + GlobalScope.launch { val result = withContext(coroutineDispatchers.crypto) { internalDecryptEvent(event, timeline) } @@ -649,7 +647,7 @@ internal class CryptoManager @Inject constructor( * @param event the event */ fun onToDeviceEvent(event: Event) { - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { when (event.getClearType()) { EventType.ROOM_KEY, EventType.FORWARDED_ROOM_KEY -> { onRoomKeyEvent(event) @@ -671,7 +669,7 @@ internal class CryptoManager @Inject constructor( */ private fun onRoomKeyEvent(event: Event) { val roomKeyContent = event.getClearContent().toModel() ?: return - if (TextUtils.isEmpty(roomKeyContent.roomId) || TextUtils.isEmpty(roomKeyContent.algorithm)) { + if (roomKeyContent.roomId.isNullOrEmpty() || roomKeyContent.algorithm.isNullOrEmpty()) { Timber.e("## onRoomKeyEvent() : missing fields") return } @@ -689,7 +687,7 @@ internal class CryptoManager @Inject constructor( * @param event the encryption event. */ private fun onRoomEncryptionEvent(roomId: String, event: Event) { - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { val params = LoadRoomMembersTask.Params(roomId) loadRoomMembersTask .execute(params) @@ -738,7 +736,7 @@ internal class CryptoManager @Inject constructor( val membership = roomMember?.membership if (membership == Membership.JOIN) { // make sure we are tracking the deviceList for this user. - deviceListManager.startTrackingDeviceList(Arrays.asList(userId)) + deviceListManager.startTrackingDeviceList(listOf(userId)) } else if (membership == Membership.INVITE && shouldEncryptForInvitedMembers(roomId) && cryptoConfig.enableEncryptionForInvitedMembers) { @@ -747,7 +745,7 @@ internal class CryptoManager @Inject constructor( // know what other servers are in the room at the time they've been invited. // They therefore will not send device updates if a user logs in whilst // their state is invite. - deviceListManager.startTrackingDeviceList(Arrays.asList(userId)) + deviceListManager.startTrackingDeviceList(listOf(userId)) } } } @@ -782,7 +780,11 @@ internal class CryptoManager @Inject constructor( * @param callback the exported keys */ override fun exportRoomKeys(password: String, callback: MatrixCallback) { - exportRoomKeys(password, MXMegolmExportEncryption.DEFAULT_ITERATION_COUNT, callback) + GlobalScope.launch(coroutineDispatchers.main) { + runCatching { + exportRoomKeys(password, MXMegolmExportEncryption.DEFAULT_ITERATION_COUNT) + }.fold(callback::onSuccess, callback::onFailure) + } } /** @@ -792,30 +794,16 @@ internal class CryptoManager @Inject constructor( * @param anIterationCount the encryption iteration count (0 means no encryption) * @param callback the exported keys */ - private fun exportRoomKeys(password: String, anIterationCount: Int, callback: MatrixCallback) { - GlobalScope.launch(coroutineDispatchers.main) { - withContext(coroutineDispatchers.crypto) { - Try { - val iterationCount = Math.max(0, anIterationCount) + private suspend fun exportRoomKeys(password: String, anIterationCount: Int): ByteArray { + return withContext(coroutineDispatchers.crypto) { + val iterationCount = max(0, anIterationCount) - val exportedSessions = ArrayList() + val exportedSessions = cryptoStore.getInboundGroupSessions().mapNotNull { it.exportKeys() } - val inboundGroupSessions = cryptoStore.getInboundGroupSessions() + val adapter = MoshiProvider.providesMoshi() + .adapter(List::class.java) - for (session in inboundGroupSessions) { - val megolmSessionData = session.exportKeys() - - if (null != megolmSessionData) { - exportedSessions.add(megolmSessionData) - } - } - - val adapter = MoshiProvider.providesMoshi() - .adapter(List::class.java) - - MXMegolmExportEncryption.encryptMegolmKeyFile(adapter.toJson(exportedSessions), password, iterationCount) - } - }.foldToCallback(callback) + MXMegolmExportEncryption.encryptMegolmKeyFile(adapter.toJson(exportedSessions), password, iterationCount) } } @@ -879,7 +867,7 @@ internal class CryptoManager @Inject constructor( */ fun checkUnknownDevices(userIds: List, callback: MatrixCallback) { // force the refresh to ensure that the devices list is up-to-date - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { deviceListManager .downloadKeys(userIds, true) .fold( @@ -944,7 +932,7 @@ internal class CryptoManager @Inject constructor( val roomIds = cryptoStore.getRoomsListBlacklistUnverifiedDevices().toMutableList() if (add) { - if (!roomIds.contains(roomId)) { + if (roomId !in roomIds) { roomIds.add(roomId) } } else { @@ -1033,8 +1021,7 @@ internal class CryptoManager @Inject constructor( val unknownDevices = MXUsersDevicesMap() val userIds = devicesInRoom.userIds for (userId in userIds) { - val deviceIds = devicesInRoom.getUserDeviceIds(userId) - deviceIds?.forEach { deviceId -> + devicesInRoom.getUserDeviceIds(userId)?.forEach { deviceId -> devicesInRoom.getObject(userId, deviceId) ?.takeIf { it.isUnknown } ?.let { @@ -1047,7 +1034,7 @@ internal class CryptoManager @Inject constructor( } override fun downloadKeys(userIds: List, forceDownload: Boolean, callback: MatrixCallback>) { - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { deviceListManager .downloadKeys(userIds, forceDownload) .foldToCallback(callback) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt index 180b7aa6..b30176b2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt @@ -29,7 +29,6 @@ import im.vector.matrix.android.internal.crypto.actions.EnsureOlmSessionsForDevi import im.vector.matrix.android.internal.crypto.actions.MessageEncrypter import im.vector.matrix.android.internal.crypto.algorithms.IMXDecrypting import im.vector.matrix.android.internal.crypto.keysbackup.KeysBackup -import im.vector.matrix.android.internal.crypto.model.MXDeviceInfo import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.crypto.model.event.RoomKeyContent @@ -38,10 +37,9 @@ import im.vector.matrix.android.internal.crypto.model.rest.RoomKeyRequestBody import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore import im.vector.matrix.android.internal.crypto.tasks.SendToDeviceTask import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import timber.log.Timber -import java.util.* import kotlin.collections.HashMap internal class MXMegolmDecryption(private val credentials: Credentials, @@ -312,7 +310,7 @@ internal class MXMegolmDecryption(private val credentials: Credentials, return } val userId = request.userId ?: return - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { deviceListManager .downloadKeys(listOf(userId), false) .flatMap { @@ -321,8 +319,7 @@ internal class MXMegolmDecryption(private val credentials: Credentials, if (deviceInfo == null) { throw RuntimeException() } else { - val devicesByUser = HashMap>() - devicesByUser[userId] = ArrayList(Arrays.asList(deviceInfo)) + val devicesByUser = mapOf(userId to listOf(deviceInfo)) ensureOlmSessionsForDevicesAction .handle(devicesByUser) .flatMap { @@ -336,8 +333,7 @@ internal class MXMegolmDecryption(private val credentials: Credentials, Timber.v("## shareKeysWithDevice() : sharing keys for session" + " ${body?.senderKey}|${body?.sessionId} with device $userId:$deviceId") - val payloadJson = HashMap() - payloadJson["type"] = EventType.FORWARDED_ROOM_KEY + val payloadJson = mutableMapOf("type" to EventType.FORWARDED_ROOM_KEY) olmDevice.getInboundGroupSession(body?.sessionId, body?.senderKey, body?.roomId) .fold( @@ -350,7 +346,7 @@ internal class MXMegolmDecryption(private val credentials: Credentials, } ) - val encodedPayload = messageEncrypter.encryptMessage(payloadJson, Arrays.asList(deviceInfo)) + val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo)) val sendToDeviceMap = MXUsersDevicesMap() sendToDeviceMap.setObject(userId, deviceId, encodedPayload) Timber.v("## shareKeysWithDevice() : sending to $userId:$deviceId") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/event/EncryptedEventContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/event/EncryptedEventContent.kt index 108ce056..4d06b737 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/event/EncryptedEventContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/model/event/EncryptedEventContent.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.internal.crypto.model.event import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent /** * Class representing an encrypted event content @@ -52,5 +53,8 @@ data class EncryptedEventContent( * The session id */ @Json(name = "session_id") - val sessionId: String? = null + val sessionId: String? = null, + + //Relation context is in clear in encrypted message + @Json(name = "m.relates_to") val relatesTo: RelationDefaultContent? = null ) \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt index 33e33ee0..54a4e14d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt @@ -40,7 +40,7 @@ import im.vector.matrix.android.internal.session.SessionScope import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import timber.log.Timber import java.util.* @@ -71,7 +71,7 @@ internal class DefaultSasVerificationService @Inject constructor(private val cre // Event received from the sync fun onToDeviceEvent(event: Event) { - CoroutineScope(coroutineDispatchers.crypto).launch { + GlobalScope.launch(coroutineDispatchers.crypto) { when (event.getClearType()) { EventType.KEY_VERIFICATION_START -> { onStartRequestReceived(event) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt index 70615293..3bda568d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt @@ -16,7 +16,6 @@ package im.vector.matrix.android.internal.database.helper -import androidx.annotation.VisibleForTesting import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.send.SendState @@ -103,7 +102,6 @@ internal fun ChunkEntity.updateSenderDataFor(eventIds: List) { } } -@VisibleForTesting internal fun ChunkEntity.add(roomId: String, event: Event, direction: PaginationDirection, @@ -134,7 +132,7 @@ internal fun ChunkEntity.add(roomId: String, } } - val localId = TimelineEventEntity.nextId(realm) + val localId = TimelineEventEntity.nextId(realm) val eventEntity = TimelineEventEntity(localId).also { it.root = event.toEntity(roomId).apply { this.stateIndex = currentStateIndex diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/RoomEntityHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/RoomEntityHelper.kt index 01d95eb2..948af2af 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/RoomEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/RoomEntityHelper.kt @@ -37,25 +37,22 @@ internal fun RoomEntity.addOrUpdate(chunkEntity: ChunkEntity) { } } -internal fun RoomEntity.addStateEvents(stateEvents: List, - stateIndex: Int = Int.MIN_VALUE, - filterDuplicates: Boolean = false, - isUnlinked: Boolean = false) { +internal fun RoomEntity.addStateEvent(stateEvent: Event, + stateIndex: Int = Int.MIN_VALUE, + filterDuplicates: Boolean = false, + isUnlinked: Boolean = false) { assertIsManaged() - - stateEvents.forEach { event -> - if (event.eventId == null || (filterDuplicates && fastContains(event.eventId))) { - return@forEach - } - val eventEntity = event.toEntity(roomId).apply { + if (stateEvent.eventId == null || (filterDuplicates && fastContains(stateEvent.eventId))) { + return + } else { + val entity = stateEvent.toEntity(roomId).apply { this.stateIndex = stateIndex this.isUnlinked = isUnlinked this.sendState = SendState.SYNCED } - untimelinedStateEvents.add(0, eventEntity) + untimelinedStateEvents.add(entity) } } - internal fun RoomEntity.addSendingEvent(event: Event) { assertIsManaged() val senderId = event.senderId ?: return @@ -64,7 +61,7 @@ internal fun RoomEntity.addSendingEvent(event: Event) { } val roomMembers = RoomMembers(realm, roomId) val myUser = roomMembers.get(senderId) - val localId = TimelineEventEntity.nextId(realm) + val localId = TimelineEventEntity.nextId(realm) val timelineEventEntity = TimelineEventEntity(localId).also { it.root = eventEntity it.eventId = event.eventId ?: "" diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt index c811ece1..a1e58c90 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt @@ -20,8 +20,6 @@ import io.realm.RealmObject import io.realm.RealmResults import io.realm.annotations.Index import io.realm.annotations.LinkingObjects -import io.realm.annotations.PrimaryKey -import java.util.* internal open class TimelineEventEntity(var localId: Long = 0, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt index d6d2d9cc..cbd4d0c6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt @@ -18,7 +18,8 @@ package im.vector.matrix.android.internal.network internal object NetworkConstants { - const val URI_API_PREFIX_PATH = "_matrix/client/" - const val URI_API_PREFIX_PATH_R0 = "_matrix/client/r0/" + private const val URI_API_PREFIX_PATH = "_matrix/client" + const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/" + const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/" -} \ No newline at end of file +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt index bbd5859b..4dfc5810 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt @@ -19,21 +19,15 @@ package im.vector.matrix.android.internal.network import arrow.core.Try import arrow.core.failure import arrow.core.recoverWith -import arrow.effects.IO -import arrow.effects.fix -import arrow.effects.instances.io.async.async -import arrow.integrations.retrofit.adapter.runAsync import com.squareup.moshi.JsonDataException import com.squareup.moshi.Moshi import im.vector.matrix.android.api.failure.Failure import im.vector.matrix.android.api.failure.MatrixError import im.vector.matrix.android.internal.di.MoshiProvider -import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.ResponseBody import retrofit2.Call import timber.log.Timber import java.io.IOException -import kotlin.coroutines.resume internal suspend inline fun executeRequest(block: Request.() -> Unit) = Request().apply(block).execute() @@ -43,30 +37,22 @@ internal class Request { lateinit var apiCall: Call suspend fun execute(): Try { - return suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { - Timber.v("Request is canceled") - apiCall.cancel() + return Try { + val response = apiCall.awaitResponse() + if (response.isSuccessful) { + response.body() + ?: throw IllegalStateException("The request returned a null body") + } else { + throw manageFailure(response.errorBody(), response.code()) } - val result = Try { - val response = apiCall.runAsync(IO.async()).fix().unsafeRunSync() - if (response.isSuccessful) { - response.body() - ?: throw IllegalStateException("The request returned a null body") - } else { - throw manageFailure(response.errorBody(), response.code()) - } - }.recoverWith { - when (it) { - is IOException -> Failure.NetworkConnection(it) - is Failure.ServerError, - is Failure.OtherServerError -> it - else -> Failure.Unknown(it) - }.failure() - } - continuation.resume(result) + }.recoverWith { + when (it) { + is IOException -> Failure.NetworkConnection(it) + is Failure.ServerError, + is Failure.OtherServerError -> it + else -> Failure.Unknown(it) + }.failure() } - } private fun manageFailure(errorBody: ResponseBody?, httpCode: Int): Throwable { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt new file mode 100644 index 00000000..7528dee2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt @@ -0,0 +1,41 @@ +/* + * + * * Copyright 2019 New Vector Ltd + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package im.vector.matrix.android.internal.network + +import kotlinx.coroutines.suspendCancellableCoroutine +import retrofit2.* +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +suspend fun Call.awaitResponse(): Response { + return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + cancel() + } + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + + override fun onFailure(call: Call, t: Throwable) { + continuation.resumeWithException(t) + } + }) + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt index 6a1cda53..09baebb2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt @@ -94,19 +94,21 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se } override fun requireBackgroundSync() { - SyncWorker.requireBackgroundSync(context, sessionParams.credentials.userId) + SyncWorker.requireBackgroundSync(context, myUserId) } override fun startAutomaticBackgroundSync(repeatDelay: Long) { - SyncWorker.automaticallyBackgroundSync(context, sessionParams.credentials.userId, 0, repeatDelay) + SyncWorker.automaticallyBackgroundSync(context, myUserId, 0, repeatDelay) } override fun stopAnyBackgroundSync() { SyncWorker.stopAnyBackgroundSync(context) } - override fun startSync() { + override fun startSync(fromForeground : Boolean) { + Timber.i("Starting sync thread") assert(isOpen) + syncThread.setInitialForeground(fromForeground) if (!syncThread.isAlive) { syncThread.start() } else { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index d1673bfe..f2e61e8c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -38,7 +38,6 @@ import im.vector.matrix.android.internal.network.RetrofitFactory import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater import im.vector.matrix.android.internal.session.room.prune.EventsPruner -import im.vector.matrix.android.internal.session.user.UserEntityUpdater import im.vector.matrix.android.internal.util.md5 import io.realm.RealmConfiguration import okhttp3.OkHttpClient @@ -129,10 +128,6 @@ internal abstract class SessionModule { @IntoSet abstract fun bindEventRelationsAggregationUpdater(groupSummaryUpdater: EventRelationsAggregationUpdater): LiveEntityObserver - @Binds - @IntoSet - abstract fun bindUserEntityUpdater(groupSummaryUpdater: UserEntityUpdater): LiveEntityObserver - @Binds abstract fun bindInitialSyncProgressService(initialSyncProgressService: DefaultInitialSyncProgressService): InitialSyncProgressService diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt index b55a7e14..867ca287 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt @@ -17,9 +17,13 @@ package im.vector.matrix.android.internal.session.room import arrow.core.Try import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.session.crypto.CryptoService +import im.vector.matrix.android.api.session.crypto.MXCryptoError import im.vector.matrix.android.api.session.events.model.* import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.relation.ReactionContent +import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult +import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.mapper.EventMapper import im.vector.matrix.android.internal.database.model.* @@ -43,7 +47,9 @@ internal interface EventRelationsAggregationTask : Task + EventAnnotationsSummaryEntity.where(realm, event.eventId + ?: "").findFirst()?.let { + TimelineEventEntity.where(realm, eventId = event.eventId + ?: "").findFirst()?.let { tet -> tet.annotations = it } } } + + EventType.ENCRYPTED -> { + //Relation type is in clear + val encryptedEventContent = event.content.toModel() + if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE) { + //we need to decrypt if needed + if (event.mxDecryptionResult == null) { + try { + val result = cryptoService.decryptEvent(event, event.roomId) + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + } catch (e: MXCryptoError) { + Timber.w("Failed to decrypt e2e replace") + //TODO -> we should keep track of this and retry, or aggregation will be broken + } + } + event.getClearContent().toModel()?.let { + Timber.v("###REPLACE in room $roomId for event ${event.eventId}") + //A replace! + handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + } + } + } EventType.REDACTION -> { val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } ?: return@forEach @@ -125,9 +160,9 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor(private } - private fun handleReplace(realm: Realm, event: Event, content: MessageContent, roomId: String, isLocalEcho: Boolean) { + private fun handleReplace(realm: Realm, event: Event, content: MessageContent, roomId: String, isLocalEcho: Boolean, relatedEventId: String? = null) { val eventId = event.eventId ?: return - val targetEventId = content.relatesTo?.eventId ?: return + val targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return val newContent = content.newContent ?: return //ok, this is a replace var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt index af263970..361a935d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt @@ -22,10 +22,11 @@ import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse +import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol import im.vector.matrix.android.internal.network.NetworkConstants import im.vector.matrix.android.internal.session.room.membership.RoomMembersResponse import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody -import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol +import im.vector.matrix.android.internal.session.room.relation.RelationsResponse import im.vector.matrix.android.internal.session.room.send.SendResponse import im.vector.matrix.android.internal.session.room.timeline.EventContextResponse import im.vector.matrix.android.internal.session.room.timeline.PaginationResponse @@ -195,6 +196,20 @@ internal interface RoomAPI { @Body content: Content? ): Call + + /** + * Paginate relations for event based in normal topological order + * + * @param relationType filter for this relation type + * @param eventType filter for this event type + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "rooms/{roomId}/relations/{eventId}/{relationType}/{eventType}") + fun getRelations(@Path("roomId") roomId: String, + @Path("eventId") eventId: String, + @Path("relationType") relationType: String, + @Path("eventType") eventType: String + ): Call + /** * Join the given room. * diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt index 3ed2fa43..9161fb25 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt @@ -20,14 +20,13 @@ import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel -import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.RoomAvatarContent +import im.vector.matrix.android.api.session.room.model.RoomMember import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.model.RoomEntity +import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.query.prev import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.session.SessionScope import im.vector.matrix.android.internal.session.room.membership.RoomMembers import javax.inject.Inject @@ -42,32 +41,25 @@ internal class RoomAvatarResolver @Inject constructor(private val monarchy: Mona fun resolve(roomId: String): String? { var res: String? = null monarchy.doWithRealm { realm -> - val roomEntity = RoomEntity.where(realm, roomId).findFirst() val roomName = EventEntity.where(realm, roomId, EventType.STATE_ROOM_AVATAR).prev()?.asDomain() res = roomName?.content.toModel()?.avatarUrl if (!res.isNullOrEmpty()) { return@doWithRealm } val roomMembers = RoomMembers(realm, roomId) - val members = roomMembers.getLoaded() - if (roomEntity?.membership == Membership.INVITE) { - if (members.size == 1) { - res = members.entries.first().value.avatarUrl - } else if (members.size > 1) { - val firstOtherMember = members.filterKeys { it != credentials.userId }.values.firstOrNull() - res = firstOtherMember?.avatarUrl - } - } else { - // detect if it is a room with no more than 2 members (i.e. an alone or a 1:1 chat) - if (members.size == 1) { - res = members.entries.first().value.avatarUrl - } else if (members.size == 2) { - val firstOtherMember = members.filterKeys { it != credentials.userId }.values.firstOrNull() - res = firstOtherMember?.avatarUrl - } + val members = roomMembers.queryRoomMembersEvent().findAll() + // detect if it is a room with no more than 2 members (i.e. an alone or a 1:1 chat) + if (members.size == 1) { + res = members.firstOrNull()?.toRoomMember()?.avatarUrl + } else if (members.size == 2) { + val firstOtherMember = members.where().notEqualTo(EventEntityFields.STATE_KEY, credentials.userId).findFirst() + res = firstOtherMember?.toRoomMember()?.avatarUrl } - } return res } + + private fun EventEntity?.toRoomMember(): RoomMember? { + return this?.asDomain()?.content?.toModel() + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index 143ef60b..98cf872b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -30,8 +30,8 @@ import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRo import im.vector.matrix.android.internal.session.room.read.DefaultReadService import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService +import im.vector.matrix.android.internal.session.room.relation.FetchEditHistoryTask import im.vector.matrix.android.internal.session.room.relation.FindReactionEventForUndoTask -import im.vector.matrix.android.internal.session.room.relation.UpdateQuickReactionTask import im.vector.matrix.android.internal.session.room.send.DefaultSendService import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory import im.vector.matrix.android.internal.session.room.state.DefaultStateService @@ -56,7 +56,7 @@ internal class RoomFactory @Inject constructor(private val context: Context, private val setReadMarkersTask: SetReadMarkersTask, private val cryptoService: CryptoService, private val findReactionEventForUndoTask: FindReactionEventForUndoTask, - private val updateQuickReactionTask: UpdateQuickReactionTask, + private val fetchEditHistoryTask: FetchEditHistoryTask, private val joinRoomTask: JoinRoomTask, private val leaveRoomTask: LeaveRoomTask) { @@ -67,7 +67,7 @@ internal class RoomFactory @Inject constructor(private val context: Context, val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask) val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask, credentials) val relationService = DefaultRelationService(context, - credentials, roomId, eventFactory, cryptoService, findReactionEventForUndoTask, monarchy, taskExecutor) + credentials, roomId, eventFactory, cryptoService, findReactionEventForUndoTask, fetchEditHistoryTask, monarchy, taskExecutor) return DefaultRoom( roomId, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 09322e6a..942239ea 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -142,4 +142,7 @@ internal abstract class RoomModule { @Binds abstract fun bindFileService(fileService: DefaultFileService): FileService + + @Binds + abstract fun bindFetchEditHistoryTask(editHistoryTask: DefaultFetchEditHistoryTask): FetchEditHistoryTask } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt index 8fdb5fe9..6bcac9b8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt @@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.RoomTopicContent import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.latestEvent @@ -86,12 +87,20 @@ internal class RoomSummaryUpdater @Inject constructor(private val credentials: C val latestEvent = TimelineEventEntity.latestEvent(realm, roomId, includedTypes = PREVIEWABLE_TYPES) val lastTopicEvent = EventEntity.where(realm, roomId, EventType.STATE_ROOM_TOPIC).prev()?.asDomain() - val otherRoomMembers = RoomMembers(realm, roomId).getLoaded().filterKeys { it != credentials.userId } + + val otherRoomMembers = RoomMembers(realm, roomId) + .queryRoomMembersEvent() + .notEqualTo(EventEntityFields.STATE_KEY, credentials.userId) + .findAll() + .asSequence() + .map { it.stateKey } + roomSummaryEntity.displayName = roomDisplayNameResolver.resolve(roomId).toString() roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(roomId) roomSummaryEntity.topic = lastTopicEvent?.content.toModel()?.topic roomSummaryEntity.latestEvent = latestEvent roomSummaryEntity.otherMemberIds.clear() - roomSummaryEntity.otherMemberIds.addAll(otherRoomMembers.keys) + roomSummaryEntity.otherMemberIds.addAll(otherRoomMembers) + } } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt index d98436a7..a3090605 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/LoadRoomMembersTask.kt @@ -17,9 +17,10 @@ package im.vector.matrix.android.internal.session.room.membership import arrow.core.Try +import com.squareup.moshi.JsonReader import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.room.model.Membership -import im.vector.matrix.android.internal.database.helper.addStateEvents +import im.vector.matrix.android.internal.database.helper.addStateEvent import im.vector.matrix.android.internal.database.helper.updateSenderData import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.query.where @@ -27,10 +28,13 @@ import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater import im.vector.matrix.android.internal.session.sync.SyncTokenStore +import im.vector.matrix.android.internal.session.user.UserEntityFactory import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.util.tryTransactionSync import io.realm.Realm import io.realm.kotlin.createObject +import okhttp3.ResponseBody +import okio.Okio import javax.inject.Inject internal interface LoadRoomMembersTask : Task { @@ -60,23 +64,26 @@ internal class DefaultLoadRoomMembersTask @Inject constructor(private val roomAP } } - private fun insertInDb(response: RoomMembersResponse, roomId: String): Try { + private fun insertInDb(response: RoomMembersResponse, roomId: String): Try { return monarchy .tryTransactionSync { realm -> // We ignore all the already known members val roomEntity = RoomEntity.where(realm, roomId).findFirst() - ?: realm.createObject(roomId) + ?: realm.createObject(roomId) - val roomMembers = RoomMembers(realm, roomId).getLoaded() - val eventsToInsert = response.roomMemberEvents.filter { !roomMembers.containsKey(it.stateKey) } - roomEntity.addStateEvents(eventsToInsert) + + for (roomMemberEvent in response.roomMemberEvents) { + roomEntity.addStateEvent(roomMemberEvent) + UserEntityFactory.createOrNull(roomMemberEvent)?.also { + realm.insertOrUpdate(it) + } + } roomEntity.chunks.flatMap { it.timelineEvents }.forEach { it.updateSenderData() } roomEntity.areAllMembersLoaded = true roomSummaryUpdater.update(realm, roomId) } - .map { response } } private fun areAllMembersAlreadyLoaded(roomId: String): Boolean { @@ -85,4 +92,4 @@ internal class DefaultLoadRoomMembersTask @Inject constructor(private val roomAP } } -} \ No newline at end of file +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt index 01ae4394..948f1741 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomDisplayNameResolver.kt @@ -25,13 +25,16 @@ import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.RoomAliasesContent import im.vector.matrix.android.api.session.room.model.RoomCanonicalAliasContent +import im.vector.matrix.android.api.session.room.model.RoomMember import im.vector.matrix.android.api.session.room.model.RoomNameContent import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.query.prev import im.vector.matrix.android.internal.database.query.where +import io.realm.RealmResults import javax.inject.Inject /** @@ -39,7 +42,6 @@ import javax.inject.Inject */ internal class RoomDisplayNameResolver @Inject constructor(private val context: Context, private val monarchy: Monarchy, - private val roomMemberDisplayNameResolver: RoomMemberDisplayNameResolver, private val credentials: Credentials ) { @@ -78,48 +80,61 @@ internal class RoomDisplayNameResolver @Inject constructor(private val context: } val roomMembers = RoomMembers(realm, roomId) - val loadedMembers = roomMembers.getLoaded() - val otherRoomMembers = loadedMembers.filterKeys { it != credentials.userId } + val loadedMembers = roomMembers.queryRoomMembersEvent().findAll() + val otherMembersSubset = loadedMembers.where() + .notEqualTo(EventEntityFields.STATE_KEY, credentials.userId) + .limit(3) + .findAll() + if (roomEntity?.membership == Membership.INVITE) { val inviteMeEvent = roomMembers.queryRoomMemberEvent(credentials.userId).findFirst() val inviterId = inviteMeEvent?.sender - name = if (inviterId != null && otherRoomMembers.containsKey(inviterId)) { - roomMemberDisplayNameResolver.resolve(inviterId, otherRoomMembers) + name = if (inviterId != null) { + val inviterMemberEvent = loadedMembers.where() + .equalTo(EventEntityFields.STATE_KEY, inviterId) + .findFirst() + inviterMemberEvent?.toRoomMember()?.displayName } else { context.getString(R.string.room_displayname_room_invite) } } else { val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() - val memberIds = if (roomSummary?.heroes?.isNotEmpty() == true) { + val memberIds: List = if (roomSummary?.heroes?.isNotEmpty() == true) { roomSummary.heroes } else { - otherRoomMembers.keys.toList() + otherMembersSubset.mapNotNull { it.stateKey } } - - val nbOfOtherMembers = memberIds.size - - when (nbOfOtherMembers) { - 0 -> name = context.getString(R.string.room_displayname_empty_room) - 1 -> name = roomMemberDisplayNameResolver.resolve(memberIds[0], otherRoomMembers) - 2 -> { - val member1 = memberIds[0] - val member2 = memberIds[1] - name = context.getString(R.string.room_displayname_two_members, - roomMemberDisplayNameResolver.resolve(member1, otherRoomMembers), - roomMemberDisplayNameResolver.resolve(member2, otherRoomMembers) - ) - } - else -> { - val member = memberIds[0] - name = context.resources.getQuantityString(R.plurals.room_displayname_three_and_more_members, - roomMembers.getNumberOfJoinedMembers() - 1, - roomMemberDisplayNameResolver.resolve(member, otherRoomMembers), - roomMembers.getNumberOfJoinedMembers() - 1) - } + name = when (memberIds.size) { + 0 -> context.getString(R.string.room_displayname_empty_room) + 1 -> resolveRoomMember(otherMembersSubset[0], roomMembers) + 2 -> context.getString(R.string.room_displayname_two_members, + resolveRoomMember(otherMembersSubset[0], roomMembers), + resolveRoomMember(otherMembersSubset[1], roomMembers) + ) + else -> context.resources.getQuantityString(R.plurals.room_displayname_three_and_more_members, + roomMembers.getNumberOfJoinedMembers() - 1, + resolveRoomMember(otherMembersSubset[0], roomMembers), + roomMembers.getNumberOfJoinedMembers() - 1) } } return@doWithRealm } return name ?: roomId } + + private fun resolveRoomMember(eventEntity: EventEntity?, + roomMembers: RoomMembers): String? { + if (eventEntity == null) return null + val roomMember = eventEntity.toRoomMember() ?: return null + val isUnique = roomMembers.isUniqueDisplayName(roomMember.displayName) + return if (isUnique) { + roomMember.displayName + } else { + "${roomMember.displayName} (${eventEntity.stateKey})" + } + } + + private fun EventEntity?.toRoomMember(): RoomMember? { + return this?.asDomain()?.content?.toModel() + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMemberDisplayNameResolver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMemberDisplayNameResolver.kt deleted file mode 100644 index 5d64a632..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMemberDisplayNameResolver.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.matrix.android.internal.session.room.membership - -import im.vector.matrix.android.api.session.room.model.RoomMember -import javax.inject.Inject - -internal class RoomMemberDisplayNameResolver @Inject constructor() { - - fun resolve(userId: String, members: Map): String? { - val currentMember = members[userId] - var displayName = currentMember?.displayName - // Get the user display name from the member list of the room - // Do not consider null display name - - if (currentMember != null && !currentMember.displayName.isNullOrEmpty()) { - val hasNameCollision = members - .filterValues { it != currentMember && it.displayName == currentMember.displayName } - .isNotEmpty() - if (hasNameCollision) { - displayName = "${currentMember.displayName} ( $userId )" - } - } - - // TODO handle invited users - /*else if (null != member && TextUtils.equals(member!!.membership, RoomMember.MEMBERSHIP_INVITE)) { - val user = (mDataHandler as MXDataHandler).getUser(userId) - if (null != user) { - displayName = user!!.displayname - } - } - */ - if (displayName == null) { - // By default, use the user ID - displayName = userId - } - return displayName - } - -} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMembers.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMembers.kt index 72b2695e..fb8326f2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMembers.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/RoomMembers.kt @@ -33,6 +33,7 @@ import io.realm.Sort * This class is an helper around STATE_ROOM_MEMBER events. * It allows to get the live membership of a user. */ + internal class RoomMembers(private val realm: Realm, private val roomId: String ) { @@ -72,27 +73,27 @@ internal class RoomMembers(private val realm: Realm, .isNotNull(EventEntityFields.CONTENT) } + fun queryJoinedRoomMembersEvent(): RealmQuery { + return queryRoomMembersEvent().contains(EventEntityFields.CONTENT, "\"membership\":\"join\"") + } + + fun queryInvitedRoomMembersEvent(): RealmQuery { + return queryRoomMembersEvent().contains(EventEntityFields.CONTENT, "\"membership\":\"invite\"") + } + fun queryRoomMemberEvent(userId: String): RealmQuery { return queryRoomMembersEvent() .equalTo(EventEntityFields.STATE_KEY, userId) } - fun getLoaded(): Map { - return queryRoomMembersEvent() - .findAll() - .map { it.asDomain() } - .associateBy { it.stateKey!! } - .mapValues { it.value.content.toModel()!! } - } - fun getNumberOfJoinedMembers(): Int { return roomSummary?.joinedMembersCount - ?: getLoaded().filterValues { it.membership == Membership.JOIN }.size + ?: queryJoinedRoomMembersEvent().findAll().size } fun getNumberOfInvitedMembers(): Int { return roomSummary?.invitedMembersCount - ?: getLoaded().filterValues { it.membership == Membership.INVITE }.size + ?: queryInvitedRoomMembersEvent().findAll().size } fun getNumberOfMembers(): Int { @@ -133,4 +134,4 @@ internal class RoomMembers(private val realm: Realm, .toList() } -} \ No newline at end of file +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt index 8ea8c337..24dc14a7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt @@ -61,13 +61,13 @@ internal class DefaultPruneEventTask @Inject constructor(private val monarchy: M } val redactionEventEntity = EventEntity.where(realm, eventId = redactionEvent.eventId - ?: "").findFirst() - ?: return + ?: "").findFirst() + ?: return val isLocalEcho = redactionEventEntity.sendState == SendState.UNSENT Timber.v("Redact event for ${redactionEvent.redacts} localEcho=$isLocalEcho") val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst() - ?: return + ?: return val allowedKeys = computeAllowedKeys(eventToPrune.type) if (allowedKeys.isNotEmpty()) { @@ -75,10 +75,11 @@ internal class DefaultPruneEventTask @Inject constructor(private val monarchy: M eventToPrune.content = ContentMapper.map(prunedContent) } else { when (eventToPrune.type) { + EventType.ENCRYPTED, EventType.MESSAGE -> { Timber.d("REDACTION for message ${eventToPrune.eventId}") val unsignedData = EventMapper.map(eventToPrune).unsignedData - ?: UnsignedData(null, null) + ?: UnsignedData(null, null) //was this event a m.replace // val contentModel = ContentMapper.map(eventToPrune.content)?.toModel() @@ -89,6 +90,8 @@ internal class DefaultPruneEventTask @Inject constructor(private val monarchy: M val modified = unsignedData.copy(redactedEvent = redactionEvent) eventToPrune.content = ContentMapper.map(emptyMap()) eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified) + eventToPrune.decryptionResultJson = null + eventToPrune.decryptionErrorCode = null } // EventType.REACTION -> { @@ -112,14 +115,14 @@ internal class DefaultPruneEventTask @Inject constructor(private val monarchy: M EventType.STATE_ROOM_CREATE -> listOf("creator") EventType.STATE_ROOM_JOIN_RULES -> listOf("join_rule") EventType.STATE_ROOM_POWER_LEVELS -> listOf("users", - "users_default", - "events", - "events_default", - "state_default", - "ban", - "kick", - "redact", - "invite") + "users_default", + "events", + "events_default", + "state_default", + "ban", + "kick", + "redact", + "invite") EventType.STATE_ROOM_ALIASES -> listOf("aliases") EventType.STATE_CANONICAL_ALIAS -> listOf("alias") EventType.FEEDBACK -> listOf("type", "target_event_id") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt index 3eb1c066..183f2a6c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt @@ -25,6 +25,7 @@ import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary +import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.relation.RelationService import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.util.Cancelable @@ -53,6 +54,7 @@ internal class DefaultRelationService @Inject constructor(private val context: C private val eventFactory: LocalEchoEventFactory, private val cryptoService: CryptoService, private val findReactionEventForUndoTask: FindReactionEventForUndoTask, + private val fetchEditHistoryTask: FetchEditHistoryTask, private val monarchy: Monarchy, private val taskExecutor: TaskExecutor) : RelationService { @@ -125,10 +127,50 @@ internal class DefaultRelationService @Inject constructor(private val context: C .also { saveLocalEcho(it) } - val workRequest = createSendEventWork(event) - TimelineSendEventWorkCommon.postWork(context, roomId, workRequest) - return CancelableWork(context, workRequest.id) + if (cryptoService.isRoomEncrypted(roomId)) { + val encryptWork = createEncryptEventWork(event, listOf("m.relates_to")) + val workRequest = createSendEventWork(event) + TimelineSendEventWorkCommon.postSequentialWorks(context, roomId, encryptWork, workRequest) + return CancelableWork(context, encryptWork.id) + } else { + val workRequest = createSendEventWork(event) + TimelineSendEventWorkCommon.postWork(context, roomId, workRequest) + return CancelableWork(context, workRequest.id) + } + + } + + override fun editReply(replyToEdit: TimelineEvent, + originalEvent: TimelineEvent, + newBodyText: String, + compatibilityBodyText: String): Cancelable { + val event = eventFactory + .createReplaceTextOfReply(roomId, + replyToEdit, + originalEvent, + newBodyText, true, MessageType.MSGTYPE_TEXT, compatibilityBodyText) + .also { + saveLocalEcho(it) + } + if (cryptoService.isRoomEncrypted(roomId)) { + val encryptWork = createEncryptEventWork(event, listOf("m.relates_to")) + val workRequest = createSendEventWork(event) + TimelineSendEventWorkCommon.postSequentialWorks(context, roomId, encryptWork, workRequest) + return CancelableWork(context, encryptWork.id) + + } else { + val workRequest = createSendEventWork(event) + TimelineSendEventWorkCommon.postWork(context, roomId, workRequest) + return CancelableWork(context, workRequest.id) + } + } + + override fun fetchEditHistory(eventId: String, callback: MatrixCallback>) { + val params = FetchEditHistoryTask.Params(roomId, cryptoService.isRoomEncrypted(roomId), eventId) + fetchEditHistoryTask.configureWith(params) + .dispatchTo(callback) + .executeBy(taskExecutor) } override fun replyToMessage(eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Cancelable? { @@ -169,7 +211,8 @@ internal class DefaultRelationService @Inject constructor(private val context: C EventAnnotationsSummaryEntity.where(realm, eventId) } return Transformations.map(liveEntity) { realmResults -> - realmResults.firstOrNull()?.asDomain() ?: EventAnnotationsSummary(eventId, emptyList(), null) + realmResults.firstOrNull()?.asDomain() + ?: EventAnnotationsSummary(eventId, emptyList(), null) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FetchEditHistoryTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FetchEditHistoryTask.kt new file mode 100644 index 00000000..7afbe288 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FetchEditHistoryTask.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.matrix.android.internal.session.room.relation + +import arrow.core.Try +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.RelationType +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.task.Task +import javax.inject.Inject + + +internal interface FetchEditHistoryTask : Task> { + + data class Params( + val roomId: String, + val isRoomEncrypted: Boolean, + val eventId: String + ) +} + + +internal class DefaultFetchEditHistoryTask @Inject constructor( + private val roomAPI: RoomAPI +) : FetchEditHistoryTask { + + override suspend fun execute(params: FetchEditHistoryTask.Params): Try> { + return executeRequest { + apiCall = roomAPI.getRelations(params.roomId, + params.eventId, + RelationType.REPLACE, + if (params.isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE) + }.map { resp -> + val events = resp.chunks.toMutableList() + resp.originalEvent?.let { events.add(it) } + events + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/VisibleRoomStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/RelationsResponse.kt similarity index 54% rename from vector/src/main/java/im/vector/riotx/features/home/room/VisibleRoomStore.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/RelationsResponse.kt index 9805607c..737165ff 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/VisibleRoomStore.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/RelationsResponse.kt @@ -13,9 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package im.vector.matrix.android.internal.session.room.relation -package im.vector.riotx.features.home.room +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.session.events.model.Event -import im.vector.riotx.core.utils.RxStore - -class VisibleRoomStore : RxStore() +@JsonClass(generateAdapter = true) +internal data class RelationsResponse( + @Json(name = "chunk") val chunks: List, + @Json(name = "original_event") val originalEvent: Event?, + @Json(name = "next_batch") val nextBatch: String?, + @Json(name = "prev_batch") val prevBatch: String? +) \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt index 67d1eabc..c6b68647 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt @@ -104,6 +104,45 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials )) } + fun createReplaceTextOfReply(roomId: String, eventReplaced: TimelineEvent, + originalEvent: TimelineEvent, + newBodyText: String, + newBodyAutoMarkdown: Boolean, + msgType: String, + compatibilityText: String): Event { + val permalink = PermalinkFactory.createPermalink(roomId, originalEvent.root.eventId ?: "") + val userLink = originalEvent.root.senderId?.let { PermalinkFactory.createPermalink(it) } + ?: "" + + val body = bodyForReply(originalEvent.getLastMessageContent(), originalEvent.root.getClearContent().toModel()) + val replyFormatted = REPLY_PATTERN.format( + permalink, + stringProvider.getString(R.string.message_reply_to_prefix), + userLink, + originalEvent.senderName ?: originalEvent.root.senderId, + body.takeFormatted(), + createTextContent(newBodyText, newBodyAutoMarkdown).takeFormatted() + ) + // + // > <@alice:example.org> This is the original body + // + val replyFallback = buildReplyFallback(body, originalEvent.root.senderId ?: "", newBodyText) + + return createEvent(roomId, + MessageTextContent( + type = msgType, + body = compatibilityText, + relatesTo = RelationDefaultContent(RelationType.REPLACE, eventReplaced.root.eventId), + newContent = MessageTextContent( + type = msgType, + format = MessageType.FORMAT_MATRIX_HTML, + body = replyFallback, + formattedBody = replyFormatted + ) + .toContent() + )) + } + fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event { return when (attachment.type) { ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment) @@ -239,16 +278,8 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null val userId = eventReplied.root.senderId ?: return null val userLink = PermalinkFactory.createPermalink(userId) ?: return null - // - //
- // In reply to - // @alice:example.org - //
- // - //
- //
- // This is where the reply goes. - val body = bodyForReply(eventReplied.getLastMessageContent()) + + val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.root.getClearContent().toModel()) val replyFormatted = REPLY_PATTERN.format( permalink, stringProvider.getString(R.string.message_reply_to_prefix), @@ -260,8 +291,22 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials // // > <@alice:example.org> This is the original body // + val replyFallback = buildReplyFallback(body, userId, replyText) + + val eventId = eventReplied.root.eventId ?: return null + val content = MessageTextContent( + type = MessageType.MSGTYPE_TEXT, + format = MessageType.FORMAT_MATRIX_HTML, + body = replyFallback, + formattedBody = replyFormatted, + relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId)) + ) + return createEvent(roomId, content) + } + + private fun buildReplyFallback(body: TextContent, originalSenderId: String?, newBodyText: String): String { val lines = body.text.split("\n") - val replyFallback = StringBuffer("><$userId>") + val replyFallback = StringBuffer("><$originalSenderId>") lines.forEachIndexed { index, s -> if (index == 0) { replyFallback.append(" $s") @@ -269,23 +314,16 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials replyFallback.append("\n>$s") } } - replyFallback.append("\n\n").append(replyText) - - val eventId = eventReplied.root.eventId ?: return null - val content = MessageTextContent( - type = MessageType.MSGTYPE_TEXT, - format = MessageType.FORMAT_MATRIX_HTML, - body = replyFallback.toString(), - formattedBody = replyFormatted, - relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId)) - ) - return createEvent(roomId, content) + replyFallback.append("\n\n").append(newBodyText) + return replyFallback.toString() } /** * Returns a TextContent used for the fallback event representation in a reply message. + * We also pass the original content, because in case of an edit of a reply the last content is not + * himself a reply, but it will contain the fallbacks, so we have to trim them. */ - private fun bodyForReply(content: MessageContent?): TextContent { + private fun bodyForReply(content: MessageContent?, originalContent: MessageContent?): TextContent { when (content?.type) { MessageType.MSGTYPE_EMOTE, MessageType.MSGTYPE_TEXT, @@ -296,7 +334,7 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials formattedText = content.formattedBody } } - val isReply = content.relatesTo?.inReplyTo?.eventId != null + val isReply = content.isReply() || originalContent.isReply() return if (isReply) TextContent(content.body, formattedText).removeInReplyFallbacks() else @@ -353,7 +391,16 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials companion object { const val LOCAL_ID_PREFIX = "local." - // No whitespace + + // + //
+ // In reply to + // @alice:example.org + //
+ // + //
+ //
+ // No whitespace because currently breaks temporary formatted text to Span const val REPLY_PATTERN = """
%s%s
%s
%s""" fun isLocalEchoId(eventId: String): Boolean = eventId.startsWith(LOCAL_ID_PREFIX) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/TextContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/TextContent.kt index 3061bd83..bf7cb361 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/TextContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/TextContent.kt @@ -18,6 +18,8 @@ package im.vector.matrix.android.internal.session.room.send import im.vector.matrix.android.api.session.room.model.message.MessageTextContent import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromHtmlReply +import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply /** * Contains a text and eventually a formatted text @@ -47,28 +49,4 @@ fun TextContent.removeInReplyFallbacks(): TextContent { ) } -private fun extractUsefulTextFromReply(repliedBody: String): String { - val lines = repliedBody.lines() - var wellFormed = repliedBody.startsWith(">") - var endOfPreviousFound = false - val usefullines = ArrayList() - lines.forEach { - if (it == "") { - endOfPreviousFound = true - return@forEach - } - if (!endOfPreviousFound) { - wellFormed = wellFormed && it.startsWith(">") - } else { - usefullines.add(it) - } - } - return usefullines.joinToString("\n").takeIf { wellFormed } ?: repliedBody -} -private fun extractUsefulTextFromHtmlReply(repliedBody: String): String { - if (repliedBody.startsWith("")) { - return repliedBody.substring(repliedBody.lastIndexOf("") + "".length).trim() - } - return repliedBody -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 87f78224..e1a8bdd7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -43,7 +43,7 @@ import kotlin.collections.ArrayList import kotlin.collections.HashMap -private const val INITIAL_LOAD_SIZE = 10 +private const val INITIAL_LOAD_SIZE = 30 private const val MIN_FETCHING_COUNT = 30 private const val DISPLAY_INDEX_UNKNOWN = Int.MIN_VALUE diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index ae60969b..fb8b6271 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -18,18 +18,14 @@ package im.vector.matrix.android.internal.session.room.timeline import arrow.core.Try import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.internal.database.helper.addAll -import im.vector.matrix.android.internal.database.helper.addOrUpdate -import im.vector.matrix.android.internal.database.helper.addStateEvents -import im.vector.matrix.android.internal.database.helper.deleteOnCascade -import im.vector.matrix.android.internal.database.helper.isUnlinked -import im.vector.matrix.android.internal.database.helper.merge +import im.vector.matrix.android.internal.database.helper.* import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.query.create import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findAllIncludingEvents import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.session.user.UserEntityFactory import im.vector.matrix.android.internal.util.tryTransactionSync import io.realm.kotlin.createObject import timber.log.Timber @@ -117,7 +113,7 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy Timber.v("Start persisting ${receivedChunk.events.size} events in $roomId towards $direction") val roomEntity = RoomEntity.where(realm, roomId).findFirst() - ?: realm.createObject(roomId) + ?: realm.createObject(roomId) val nextToken: String? val prevToken: String? @@ -146,15 +142,21 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy } else { nextChunk?.apply { this.prevToken = prevToken } } - ?: ChunkEntity.create(realm, prevToken, nextToken) + ?: ChunkEntity.create(realm, prevToken, nextToken) if (receivedChunk.events.isEmpty() && receivedChunk.end == receivedChunk.start) { Timber.v("Reach end of $roomId") currentChunk.isLastBackward = true } else { Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken} | ${currentChunk.prevToken}") - currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked()) - + val eventIds = ArrayList(receivedChunk.events.size) + for (event in receivedChunk.events) { + event.eventId?.also { eventIds.add(it) } + currentChunk.add(roomId, event, direction, isUnlinked = currentChunk.isUnlinked()) + UserEntityFactory.createOrNull(event)?.also { + realm.insertOrUpdate(it) + } + } // Then we merge chunks if needed if (currentChunk != prevChunk && prevChunk != null) { currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk) @@ -170,7 +172,13 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy } } roomEntity.addOrUpdate(currentChunk) - roomEntity.addStateEvents(receivedChunk.stateEvents, isUnlinked = currentChunk.isUnlinked()) + for (stateEvent in receivedChunk.stateEvents) { + roomEntity.addStateEvent(stateEvent, isUnlinked = currentChunk.isUnlinked()) + UserEntityFactory.createOrNull(stateEvent)?.also { + realm.insertOrUpdate(it) + } + } + currentChunk.updateSenderDataFor(eventIds) } } .map { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt index bdf81064..215321bd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt @@ -24,12 +24,11 @@ import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.tag.RoomTagContent import im.vector.matrix.android.internal.crypto.CryptoManager -import im.vector.matrix.android.internal.database.helper.addAll -import im.vector.matrix.android.internal.database.helper.addOrUpdate -import im.vector.matrix.android.internal.database.helper.addStateEvents -import im.vector.matrix.android.internal.database.helper.lastStateIndex +import im.vector.matrix.android.internal.database.helper.* import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.model.RoomEntity +import im.vector.matrix.android.internal.database.model.UserEntity import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.where @@ -40,6 +39,7 @@ import im.vector.matrix.android.internal.session.notification.ProcessEventForPus import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.sync.model.* +import im.vector.matrix.android.internal.session.user.UserEntityFactory import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith import io.realm.Realm @@ -125,51 +125,31 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch } roomEntity.membership = Membership.JOIN - val lastChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) - val isInitialSync = lastChunk == null - val lastStateIndex = lastChunk?.lastStateIndex(PaginationDirection.FORWARDS) ?: 0 - val numberOfStateEvents = roomSync.state?.events?.size ?: 0 - val stateIndexOffset = lastStateIndex + numberOfStateEvents - // State event if (roomSync.state != null && roomSync.state.events.isNotEmpty()) { - val untimelinedStateIndex = if (isInitialSync) Int.MIN_VALUE else stateIndexOffset - roomEntity.addStateEvents(roomSync.state.events, filterDuplicates = true, stateIndex = untimelinedStateIndex) - - // Give info to crypto module - roomSync.state.events.forEach { - cryptoManager.onStateEvent(roomId, it) + val minStateIndex = roomEntity.untimelinedStateEvents.where().min(EventEntityFields.STATE_INDEX)?.toInt() + ?: Int.MIN_VALUE + val untimelinedStateIndex = minStateIndex + 1 + roomSync.state.events.forEach { event -> + roomEntity.addStateEvent(event, filterDuplicates = true, stateIndex = untimelinedStateIndex) + // Give info to crypto module + cryptoManager.onStateEvent(roomId, event) + UserEntityFactory.createOrNull(event)?.also { + realm.insertOrUpdate(it) + } } } if (roomSync.timeline != null && roomSync.timeline.events.isNotEmpty()) { - val timelineStateOffset = if (isInitialSync || roomSync.timeline.limited.not()) 0 else stateIndexOffset val chunkEntity = handleTimelineEvents( realm, - roomId, + roomEntity, roomSync.timeline.events, roomSync.timeline.prevToken, roomSync.timeline.limited, - timelineStateOffset + 0 ) roomEntity.addOrUpdate(chunkEntity) - - // Give info to crypto module - roomSync.timeline.events.forEach { - cryptoManager.onLiveEvent(roomId, it) - } - - // Try to remove local echo - val transactionIds = roomSync.timeline.events.mapNotNull { it.unsignedData?.transactionId } - transactionIds.forEach { - val sendingEventEntity = roomEntity.sendingTimelineEvents.find(it) - if (sendingEventEntity != null) { - Timber.v("Remove local echo for tx:$it") - roomEntity.sendingTimelineEvents.remove(sendingEventEntity) - } else { - Timber.v("Can't find corresponding local echo for tx:$it") - } - } } roomSummaryUpdater.update(realm, roomId, Membership.JOIN, roomSync.summary, roomSync.unreadNotifications) @@ -192,7 +172,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch ?: realm.createObject(roomId) roomEntity.membership = Membership.INVITE if (roomSync.inviteState != null && roomSync.inviteState.events.isNotEmpty()) { - val chunkEntity = handleTimelineEvents(realm, roomId, roomSync.inviteState.events) + val chunkEntity = handleTimelineEvents(realm, roomEntity, roomSync.inviteState.events) roomEntity.addOrUpdate(chunkEntity) } roomSummaryUpdater.update(realm, roomId, Membership.INVITE) @@ -212,13 +192,13 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch } private fun handleTimelineEvents(realm: Realm, - roomId: String, + roomEntity: RoomEntity, eventList: List, prevToken: String? = null, isLimited: Boolean = true, stateIndexOffset: Int = 0): ChunkEntity { - val lastChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) + val lastChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomEntity.roomId) val chunkEntity = if (!isLimited && lastChunk != null) { lastChunk } else { @@ -226,13 +206,32 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch } lastChunk?.isLastForward = false chunkEntity.isLastForward = true - chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS, stateIndexOffset) - - //update eventAnnotationSummary here? + val eventIds = ArrayList(eventList.size) + for (event in eventList) { + event.eventId?.also { eventIds.add(it) } + chunkEntity.add(roomEntity.roomId, event, PaginationDirection.FORWARDS, stateIndexOffset) + // Give info to crypto module + cryptoManager.onLiveEvent(roomEntity.roomId, event) + // Try to remove local echo + event.unsignedData?.transactionId?.also { + val sendingEventEntity = roomEntity.sendingTimelineEvents.find(it) + if (sendingEventEntity != null) { + Timber.v("Remove local echo for tx:$it") + roomEntity.sendingTimelineEvents.remove(sendingEventEntity) + } else { + Timber.v("Can't find corresponding local echo for tx:$it") + } + } + UserEntityFactory.createOrNull(event)?.also { + realm.insertOrUpdate(it) + } + } + chunkEntity.updateSenderDataFor(eventIds) return chunkEntity } + private fun handleEphemeral(realm: Realm, roomId: String, ephemeral: RoomSyncEphemeral) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt index 3d84d63e..598d5f07 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt @@ -20,8 +20,6 @@ import dagger.Binds import dagger.Module import dagger.Provides import im.vector.matrix.android.internal.session.SessionScope -import im.vector.matrix.android.internal.session.user.DefaultUpdateUserTask -import im.vector.matrix.android.internal.session.user.UpdateUserTask import retrofit2.Retrofit @Module diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt index b6d236ed..c08f6101 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt @@ -37,7 +37,6 @@ import javax.inject.Inject private const val RETRY_WAIT_TIME_MS = 10_000L private const val DEFAULT_LONG_POOL_TIMEOUT = 30_000L -private const val DEFAULT_LONG_POOL_DELAY = 0L internal class SyncThread @Inject constructor(private val syncTask: SyncTask, private val networkConnectivityChecker: NetworkConnectivityChecker, @@ -54,10 +53,15 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, updateStateTo(SyncState.IDLE) } + fun setInitialForeground(initialForeground: Boolean) { + val newState = if (initialForeground) SyncState.IDLE else SyncState.PAUSED + updateStateTo(newState) + } + fun restart() = synchronized(lock) { if (state is SyncState.PAUSED) { Timber.v("Resume sync...") - updateStateTo(SyncState.RUNNING(catchingUp = true)) + updateStateTo(SyncState.RUNNING(afterPause = true)) lock.notify() } } @@ -84,7 +88,6 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, Timber.v("Start syncing...") networkConnectivityChecker.register(this) backgroundDetectionObserver.register(this) - updateStateTo(SyncState.RUNNING(catchingUp = true)) while (state != SyncState.KILLING) { if (!networkConnectivityChecker.isConnected() || state == SyncState.PAUSED) { @@ -93,7 +96,10 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, lock.wait() } } else { - Timber.v("Execute sync request with timeout $DEFAULT_LONG_POOL_TIMEOUT") + if (state !is SyncState.RUNNING) { + updateStateTo(SyncState.RUNNING(afterPause = true)) + } + Timber.v("[$this] Execute sync request with timeout $DEFAULT_LONG_POOL_TIMEOUT") val latch = CountDownLatch(1) val params = SyncTask.Params(DEFAULT_LONG_POOL_TIMEOUT) cancelableTask = syncTask.configureWith(params) @@ -133,11 +139,9 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, latch.await() if (state is SyncState.RUNNING) { - updateStateTo(SyncState.RUNNING(catchingUp = false)) + updateStateTo(SyncState.RUNNING(afterPause = false)) } - Timber.v("Waiting for $DEFAULT_LONG_POOL_DELAY delay before new pool...") - if (DEFAULT_LONG_POOL_DELAY > 0) sleep(DEFAULT_LONG_POOL_DELAY) Timber.v("...Continue") } } @@ -148,6 +152,7 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, } private fun updateStateTo(newState: SyncState) { + Timber.v("Update state to $newState") state = newState liveState.postValue(newState) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UpdateUserTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UpdateUserTask.kt deleted file mode 100644 index eb331e41..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UpdateUserTask.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.matrix.android.internal.session.user - -import arrow.core.Try -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.room.model.Membership -import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.model.UserEntity -import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.session.SessionScope -import im.vector.matrix.android.internal.session.room.membership.RoomMembers -import im.vector.matrix.android.internal.task.Task -import im.vector.matrix.android.internal.util.tryTransactionSync -import javax.inject.Inject - -internal interface UpdateUserTask : Task { - - data class Params(val eventIds: List) - -} - -internal class DefaultUpdateUserTask @Inject constructor(private val monarchy: Monarchy) : UpdateUserTask { - - override suspend fun execute(params: UpdateUserTask.Params): Try { - return monarchy.tryTransactionSync { realm -> - params.eventIds.forEach { eventId -> - val event = EventEntity.where(realm, eventId).findFirst()?.asDomain() - ?: return@forEach - val roomId = event.roomId ?: return@forEach - val userId = event.stateKey ?: return@forEach - val roomMember = RoomMembers(realm, roomId).get(userId) ?: return@forEach - if (roomMember.membership != Membership.JOIN) return@forEach - - val userEntity = UserEntity.where(realm, userId).findFirst() - ?: realm.createObject(UserEntity::class.java, userId) - userEntity.displayName = roomMember.displayName ?: "" - userEntity.avatarUrl = roomMember.avatarUrl ?: "" - } - } - } - -} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserEntityFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserEntityFactory.kt new file mode 100644 index 00000000..188c7d84 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserEntityFactory.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.user + +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.model.RoomMember +import im.vector.matrix.android.internal.database.model.UserEntity + +internal object UserEntityFactory { + + fun createOrNull(event: Event): UserEntity? { + if (event.type != EventType.STATE_ROOM_MEMBER) { + return null + } + val roomMember = event.content.toModel() ?: return null + return UserEntity(event.stateKey ?: "", + roomMember.displayName ?: "", + roomMember.avatarUrl ?: "" + ) + } + + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserEntityUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserEntityUpdater.kt deleted file mode 100644 index 5c722863..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserEntityUpdater.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.matrix.android.internal.session.user - -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.model.EventEntityFields -import im.vector.matrix.android.internal.database.query.types -import im.vector.matrix.android.internal.di.SessionDatabase -import im.vector.matrix.android.internal.task.TaskExecutor -import im.vector.matrix.android.internal.task.TaskThread -import im.vector.matrix.android.internal.task.configureWith -import io.realm.OrderedCollectionChangeSet -import io.realm.RealmConfiguration -import io.realm.RealmResults -import io.realm.Sort -import javax.inject.Inject - -internal class UserEntityUpdater @Inject constructor(@SessionDatabase realmConfiguration: RealmConfiguration, - private val updateUserTask: UpdateUserTask, - private val taskExecutor: TaskExecutor) - : RealmLiveEntityObserver(realmConfiguration) { - - override val query = Monarchy.Query { - EventEntity - .types(it, listOf(EventType.STATE_ROOM_MEMBER)) - .sort(EventEntityFields.STATE_INDEX, Sort.DESCENDING) - .distinct(EventEntityFields.STATE_KEY) - } - - override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - val roomMembersEvents = changeSet.insertions - .asSequence() - .mapNotNull { results[it]?.eventId } - .toList() - - val taskParams = UpdateUserTask.Params(roomMembersEvents) - updateUserTask - .configureWith(taskParams) - .executeOn(TaskThread.IO) - .executeBy(taskExecutor) - } - -} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt index a35f5a3b..00368dfa 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt @@ -26,7 +26,4 @@ internal abstract class UserModule { @Binds abstract fun bindUserService(userService: DefaultUserService): UserService - @Binds - abstract fun bindUpdateUserTask(updateUserTask: DefaultUpdateUserTask): UpdateUserTask - } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-eu/strings.xml b/matrix-sdk-android/src/main/res/values-eu/strings.xml index 00bcf69e..a211ea04 100644 --- a/matrix-sdk-android/src/main/res/values-eu/strings.xml +++ b/matrix-sdk-android/src/main/res/values-eu/strings.xml @@ -119,7 +119,7 @@ Robota Txanoa Betaurrekoak - Giltza ingelesa + Giltza Santa Ederto Aterkia diff --git a/matrix-sdk-android/src/main/res/values-ko/strings.xml b/matrix-sdk-android/src/main/res/values-ko/strings.xml index ede965c1..faf1840a 100644 --- a/matrix-sdk-android/src/main/res/values-ko/strings.xml +++ b/matrix-sdk-android/src/main/res/values-ko/strings.xml @@ -2,4 +2,5 @@ %1$s: %2$s %s\'의 초대 + 헤드폰 diff --git a/matrix-sdk-android/src/main/res/values/strings.xml b/matrix-sdk-android/src/main/res/values/strings.xml index be64a659..5459cf91 100644 --- a/matrix-sdk-android/src/main/res/values/strings.xml +++ b/matrix-sdk-android/src/main/res/values/strings.xml @@ -228,4 +228,14 @@ Pin + + Initial Sync:\nImporting account… + Initial Sync:\nImporting crypto + Initial Sync:\nImporting Rooms + Initial Sync:\nImporting Joined Rooms + Initial Sync:\nImporting Invited Rooms + Initial Sync:\nImporting Left Rooms + Initial Sync:\nImporting Communities + Initial Sync:\nImporting Account Data + diff --git a/matrix-sdk-android/src/main/res/values/strings_RiotX.xml b/matrix-sdk-android/src/main/res/values/strings_RiotX.xml index d03dfa79..0d2c4cc4 100644 --- a/matrix-sdk-android/src/main/res/values/strings_RiotX.xml +++ b/matrix-sdk-android/src/main/res/values/strings_RiotX.xml @@ -1,10 +1,4 @@ + - Initial Sync:\nImporting account… - Initial Sync:\nImporting crypto - Initial Sync:\nImporting Rooms - Initial Sync:\nImporting Joined Rooms - Initial Sync:\nImporting Invited Rooms - Initial Sync:\nImporting Left Rooms - Initial Sync:\nImporting Communities - Initial Sync:\nImporting Account Data + \ No newline at end of file diff --git a/vector/build.gradle b/vector/build.gradle index b4f27d7e..6a7a8e3a 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -13,7 +13,7 @@ androidExtensions { } ext.versionMajor = 0 -ext.versionMinor = 1 +ext.versionMinor = 2 ext.versionPatch = 0 static def getGitTimestamp() { @@ -96,6 +96,9 @@ android { buildTypes { debug { + applicationIdSuffix ".debug" + resValue "string", "app_name", "RiotX dbg" + resValue "bool", "debug_mode", "true" buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false" @@ -103,6 +106,8 @@ android { } release { + resValue "string", "app_name", "RiotX" + resValue "bool", "debug_mode", "false" buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false" @@ -182,8 +187,9 @@ dependencies { implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' implementation 'com.jakewharton.rxrelay2:rxrelay:2.1.0' - // TODO RxBindings3 exists - implementation 'com.jakewharton.rxbinding2:rxbinding:2.2.0' + // RXBinding + implementation 'com.jakewharton.rxbinding3:rxbinding:3.0.0-alpha2' + implementation 'com.jakewharton.rxbinding3:rxbinding-appcompat:3.0.0-alpha2' implementation("com.airbnb.android:epoxy:$epoxy_version") kapt "com.airbnb.android:epoxy-processor:$epoxy_version" @@ -249,6 +255,8 @@ dependencies { exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' } + implementation 'diff_match_patch:diff_match_patch:current' + // TESTS testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.2.0' diff --git a/vector/src/gplay/debug/google-services.json b/vector/src/gplay/debug/google-services.json new file mode 100644 index 00000000..185f7afb --- /dev/null +++ b/vector/src/gplay/debug/google-services.json @@ -0,0 +1,40 @@ +{ + "project_info": { + "project_number": "912726360885", + "firebase_url": "https://vector-alpha.firebaseio.com", + "project_id": "vector-alpha", + "storage_bucket": "vector-alpha.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:912726360885:android:4ef8f3a0021e774d", + "android_client_info": { + "package_name": "im.vector.riotx.debug" + } + }, + "oauth_client": [ + { + "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "912726360885-rsae0i66rgqt6ivnudu1pv4tksg9i8b2.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/vector/src/gplay/java/im/vector/riotx/gplay/push/fcm/VectorFirebaseMessagingService.kt b/vector/src/gplay/java/im/vector/riotx/gplay/push/fcm/VectorFirebaseMessagingService.kt index 6c9f16c6..5a4aa36c 100755 --- a/vector/src/gplay/java/im/vector/riotx/gplay/push/fcm/VectorFirebaseMessagingService.kt +++ b/vector/src/gplay/java/im/vector/riotx/gplay/push/fcm/VectorFirebaseMessagingService.kt @@ -199,7 +199,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { if (eventType == null) { //Just add a generic unknown event val simpleNotifiableEvent = SimpleNotifiableEvent( - session.sessionParams.credentials.userId, + session.myUserId, eventId, true, //It's an issue in this case, all event will bing even if expected to be silent. title = getString(R.string.notification_unknown_new_event), @@ -238,7 +238,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { } notifiableEvent.isPushGatewayEvent = true - notifiableEvent.matrixID = session.sessionParams.credentials.userId + notifiableEvent.matrixID = session.myUserId notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) notificationDrawerManager.refreshNotificationDrawer() } diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 7e5242ab..e0deced9 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -58,8 +58,10 @@ + + diff --git a/vector/src/main/assets/open_source_licenses.html b/vector/src/main/assets/open_source_licenses.html index cec7c718..9e87d466 100755 --- a/vector/src/main/assets/open_source_licenses.html +++ b/vector/src/main/assets/open_source_licenses.html @@ -344,6 +344,13 @@ SOFTWARE.
Copyright (c) 2018, Jaisel Rahman +
  • + diff-match-patch +
    + Copyright 2018 The diff-match-patch Authors. https://github.com/google/diff-match-patch +
  • + +
     Apache License
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    index 06f65129..a42eec49 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    @@ -32,13 +32,14 @@ import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupStep1Frag
     import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupStep2Fragment
     import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupStep3Fragment
     import im.vector.riotx.features.crypto.verification.SASVerificationIncomingFragment
    -import im.vector.riotx.features.home.*
    +import im.vector.riotx.features.home.HomeActivity
    +import im.vector.riotx.features.home.HomeDetailFragment
    +import im.vector.riotx.features.home.HomeDrawerFragment
    +import im.vector.riotx.features.home.HomeModule
     import im.vector.riotx.features.home.group.GroupListFragment
     import im.vector.riotx.features.home.room.detail.RoomDetailFragment
    -import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet
    -import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuFragment
    -import im.vector.riotx.features.home.room.detail.timeline.action.QuickReactionFragment
    -import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet
    +import im.vector.riotx.features.home.room.detail.timeline.action.*
    +import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity
     import im.vector.riotx.features.home.room.list.RoomListFragment
     import im.vector.riotx.features.invite.VectorInviteView
     import im.vector.riotx.features.login.LoginActivity
    @@ -50,6 +51,7 @@ import im.vector.riotx.features.rageshake.RageShake
     import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
     import im.vector.riotx.features.roomdirectory.PublicRoomsFragment
     import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity
    +import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity
     import im.vector.riotx.features.roomdirectory.createroom.CreateRoomFragment
     import im.vector.riotx.features.roomdirectory.picker.RoomDirectoryPickerFragment
     import im.vector.riotx.features.roomdirectory.roompreview.RoomPreviewNoPreviewFragment
    @@ -93,6 +95,8 @@ interface ScreenComponent {
     
         fun inject(viewReactionBottomSheet: ViewReactionBottomSheet)
     
    +    fun inject(viewEditHistoryBottomSheet: ViewEditHistoryBottomSheet)
    +
         fun inject(messageMenuFragment: MessageMenuFragment)
     
         fun inject(vectorSettingsActivity: VectorSettingsActivity)
    @@ -131,6 +135,10 @@ interface ScreenComponent {
     
         fun inject(imageMediaViewerActivity: ImageMediaViewerActivity)
     
    +    fun inject(filteredRoomsActivity: FilteredRoomsActivity)
    +
    +    fun inject(createRoomActivity: CreateRoomActivity)
    +
         fun inject(vectorInviteView: VectorInviteView)
     
         fun inject(videoMediaViewerActivity: VideoMediaViewerActivity)
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt
    index 234d4a0c..534a346a 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt
    @@ -29,11 +29,7 @@ import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsVie
     import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsViewModel_AssistedFactory
     import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupSharedViewModel
     import im.vector.riotx.features.crypto.verification.SasVerificationViewModel
    -import im.vector.riotx.features.home.HomeActivityViewModel
    -import im.vector.riotx.features.home.HomeActivityViewModel_AssistedFactory
    -import im.vector.riotx.features.home.HomeDetailViewModel
    -import im.vector.riotx.features.home.HomeDetailViewModel_AssistedFactory
    -import im.vector.riotx.features.home.HomeNavigationViewModel
    +import im.vector.riotx.features.home.*
     import im.vector.riotx.features.home.group.GroupListViewModel
     import im.vector.riotx.features.home.group.GroupListViewModel_AssistedFactory
     import im.vector.riotx.features.home.room.detail.RoomDetailViewModel
    @@ -59,11 +55,17 @@ import im.vector.riotx.features.workers.signout.SignOutViewModel
     
     @Module
     interface ViewModelModule {
    -    
     
    +
    +    /**
    +     * ViewModels with @IntoMap will be injected by this factory
    +     */
         @Binds
         fun bindViewModelFactory(factory: VectorViewModelFactory): ViewModelProvider.Factory
     
    +    /**
    +     *  Below are bindings for the androidx view models (which extend ViewModel). Will be converted to MvRx ViewModel in the future.
    +     */
         @Binds
         @IntoMap
         @ViewModelKey(SignOutViewModel::class)
    @@ -114,6 +116,10 @@ interface ViewModelModule {
         @ViewModelKey(ConfigurationViewModel::class)
         fun bindConfigurationViewModel(viewModel: ConfigurationViewModel): ViewModel
     
    +    /**
    +     * Below are bindings for the MvRx view models (which extend VectorViewModel). Will be the only usage in the future.
    +     */
    +
         @Binds
         fun bindHomeActivityViewModelFactory(factory: HomeActivityViewModel_AssistedFactory): HomeActivityViewModel.Factory
     
    @@ -156,6 +162,9 @@ interface ViewModelModule {
         @Binds
         fun bindViewReactionViewModelFactory(factory: ViewReactionViewModel_AssistedFactory): ViewReactionViewModel.Factory
     
    +    @Binds
    +    fun bindViewEditHistoryViewModelFactory(factory: ViewEditHistoryViewModel_AssistedFactory): ViewEditHistoryViewModel.Factory
    +
         @Binds
         fun bindCreateRoomViewModelFactory(factory: CreateRoomViewModel_AssistedFactory): CreateRoomViewModel.Factory
     
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt
    index 75817779..b4afb569 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt
    @@ -23,8 +23,8 @@ fun AppCompatActivity.addFragment(fragment: Fragment, frameId: Int) {
         supportFragmentManager.inTransaction { add(frameId, fragment) }
     }
     
    -fun AppCompatActivity.replaceFragment(fragment: Fragment, frameId: Int) {
    -    supportFragmentManager.inTransaction { replace(frameId, fragment) }
    +fun AppCompatActivity.replaceFragment(fragment: Fragment, frameId: Int, tag: String? = null) {
    +    supportFragmentManager.inTransaction { replace(frameId, fragment, tag) }
     }
     
     fun AppCompatActivity.addFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    index b03686d4..95e17a4b 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    @@ -16,14 +16,20 @@
     
     package im.vector.riotx.core.extensions
     
    +import androidx.lifecycle.Lifecycle
    +import androidx.lifecycle.ProcessLifecycleOwner
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.sync.FilterService
     import im.vector.riotx.features.notifications.PushRuleTriggerListener
    +import timber.log.Timber
     
     fun Session.configureAndStart(pushRuleTriggerListener: PushRuleTriggerListener) {
         open()
         setFilter(FilterService.FilterPreset.RiotFilter)
    -    startSync()
    +    Timber.i("Configure and start session for ${this.myUserId}")
    +    val isAtLeastStarted = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
    +    Timber.v("--> is at least started? $isAtLeastStarted")
    +    startSync(isAtLeastStarted)
         refreshPushers()
         pushRuleTriggerListener.startWithSession(this)
         fetchPushRules()
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt
    index c82631c0..db171300 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt
    @@ -21,5 +21,5 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
     
     fun TimelineEvent.canReact(): Boolean {
         // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
    -    return root.getClearType() == EventType.MESSAGE && sendState.isSent()
    +    return root.getClearType() == EventType.MESSAGE && sendState.isSent() && !root.isRedacted()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorViewModel.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorViewModel.kt
    index 7a79bf37..1570a7f8 100644
    --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorViewModel.kt
    @@ -18,7 +18,18 @@ package im.vector.riotx.core.platform
     
     import com.airbnb.mvrx.BaseMvRxViewModel
     import com.airbnb.mvrx.MvRxState
    +import im.vector.matrix.android.api.util.CancelableBag
     import im.vector.riotx.BuildConfig
     
     abstract class VectorViewModel(initialState: S)
    -    : BaseMvRxViewModel(initialState, false)
    \ No newline at end of file
    +    : BaseMvRxViewModel(initialState, false) {
    +
    +    protected val cancelableBag = CancelableBag()
    +
    +    override fun onCleared() {
    +        super.onCleared()
    +        cancelableBag.cancel()
    +    }
    +
    +
    +}
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt b/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt
    index e5e84514..b0747e7e 100755
    --- a/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt
    @@ -58,10 +58,10 @@ open class UserAvatarPreference : Preference {
         open fun refreshAvatar() {
             val session = mSession ?: return
             val view = mAvatarView ?: return
    -        session.getUser(session.sessionParams.credentials.userId)?.let {
    +        session.getUser(session.myUserId)?.let {
                 avatarRenderer.render(it, view)
             } ?: run {
    -            avatarRenderer.render(null, session.sessionParams.credentials.userId, null, view)
    +            avatarRenderer.render(null, session.myUserId, null, view)
             }
     
         }
    diff --git a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt
    index ebaeb2d3..f0a62ccd 100644
    --- a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt
    @@ -51,7 +51,7 @@ abstract class GenericItem : VectorEpoxyModel() {
         var title: String? = null
     
         @EpoxyAttribute
    -    var description: String? = null
    +    var description: CharSequence? = null
     
         @EpoxyAttribute
         var style: STYLE = STYLE.NORMAL_TEXT
    diff --git a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemHeader.kt b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemHeader.kt
    new file mode 100644
    index 00000000..3c9ce20d
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemHeader.kt
    @@ -0,0 +1,42 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +package im.vector.riotx.core.ui.list
    +
    +import android.widget.TextView
    +import com.airbnb.epoxy.EpoxyAttribute
    +import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.riotx.R
    +import im.vector.riotx.core.epoxy.VectorEpoxyHolder
    +import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.core.extensions.setTextOrHide
    +
    +/**
    + * A generic list item header left aligned with notice color.
    + */
    +@EpoxyModelClass(layout = R.layout.item_generic_header)
    +abstract class GenericItemHeader : VectorEpoxyModel() {
    +
    +    @EpoxyAttribute
    +    var text: String? = null
    +
    +    override fun bind(holder: Holder) {
    +        holder.text.setTextOrHide(text)
    +    }
    +
    +    class Holder : VectorEpoxyHolder() {
    +        val text by bind(R.id.itemGenericHeaderText)
    +    }
    +}
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericLoaderItem.kt b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericLoaderItem.kt
    new file mode 100644
    index 00000000..56daca22
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericLoaderItem.kt
    @@ -0,0 +1,20 @@
    +package im.vector.riotx.core.ui.list
    +
    +import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.riotx.R
    +import im.vector.riotx.core.epoxy.VectorEpoxyHolder
    +import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +
    +
    +/**
    + * A generic list item header left aligned with notice color.
    + */
    +@EpoxyModelClass(layout = R.layout.item_generic_loader)
    +abstract class GenericLoaderItem : VectorEpoxyModel() {
    +
    +    //Maybe/Later add some style configuration, SMALL/BIG ?
    +
    +    override fun bind(holder: Holder) {}
    +
    +    class Holder : VectorEpoxyHolder()
    +}
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt b/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt
    index f387b296..9c7b7938 100644
    --- a/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/utils/SystemUtils.kt
    @@ -28,6 +28,7 @@ import android.os.Build
     import android.os.PowerManager
     import android.provider.Settings
     import android.widget.Toast
    +import androidx.annotation.StringRes
     import androidx.appcompat.app.AppCompatActivity
     import androidx.fragment.app.Fragment
     import im.vector.riotx.R
    @@ -81,11 +82,11 @@ fun requestDisablingBatteryOptimization(activity: Activity, fragment: Fragment?,
      * @param context the context
      * @param text    the text to copy
      */
    -fun copyToClipboard(context: Context, text: CharSequence, showToast: Boolean = true) {
    +fun copyToClipboard(context: Context, text: CharSequence, showToast: Boolean = true, @StringRes toastMessage : Int = R.string.copied_to_clipboard) {
         val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
         clipboard.primaryClip = ClipData.newPlainText("", text)
         if (showToast) {
    -        context.toast(R.string.copied_to_clipboard)
    +        context.toast(toastMessage)
         }
     }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt
    index c4da3038..1d7a6a35 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyViewModel.kt
    @@ -21,8 +21,8 @@ import androidx.lifecycle.ViewModel
     import im.vector.matrix.android.api.MatrixCallback
     import im.vector.matrix.android.api.listeners.StepProgressListener
     import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupService
    -import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult
     import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeysVersionResult
    +import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult
     import im.vector.riotx.R
     import im.vector.riotx.core.platform.WaitingViewData
     import im.vector.riotx.core.ui.views.KeysBackupBanner
    @@ -57,7 +57,7 @@ class KeysBackupRestoreFromKeyViewModel @Inject constructor() : ViewModel() {
             keysBackup.restoreKeysWithRecoveryKey(keysVersionResult,
                     recoveryKey,
                     null,
    -                session.sessionParams.credentials.userId,
    +                session.myUserId,
                     object : StepProgressListener {
                         override fun onStepProgress(step: StepProgressListener.Step) {
                             when (step) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseViewModel.kt
    index 3a4a9528..45995434 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseViewModel.kt
    @@ -21,8 +21,8 @@ import androidx.lifecycle.ViewModel
     import im.vector.matrix.android.api.MatrixCallback
     import im.vector.matrix.android.api.listeners.StepProgressListener
     import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupService
    -import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult
     import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeysVersionResult
    +import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult
     import im.vector.riotx.R
     import im.vector.riotx.core.platform.WaitingViewData
     import im.vector.riotx.core.ui.views.KeysBackupBanner
    @@ -58,7 +58,7 @@ class KeysBackupRestoreFromPassphraseViewModel @Inject constructor() : ViewModel
             keysBackup.restoreKeyBackupWithPassword(keysVersionResult,
                     passphrase.value!!,
                     null,
    -                sharedViewModel.session.sessionParams.credentials.userId,
    +                sharedViewModel.session.myUserId,
                     object : StepProgressListener {
                         override fun onStepProgress(step: StepProgressListener.Step) {
                             when (step) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt
    index 67d1af4e..4ec2c0ad 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt
    @@ -41,6 +41,7 @@ import im.vector.riotx.core.platform.ToolbarConfigurable
     import im.vector.riotx.core.platform.VectorBaseActivity
     import im.vector.riotx.core.pushers.PushersManager
     import im.vector.riotx.features.disclaimer.showDisclaimerDialog
    +import im.vector.riotx.features.navigation.Navigator
     import im.vector.riotx.features.notifications.NotificationDrawerManager
     import im.vector.riotx.features.rageshake.VectorUncaughtExceptionHandler
     import im.vector.riotx.features.workers.signout.SignOutViewModel
    @@ -64,6 +65,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
         @Inject lateinit var activeSessionHolder: ActiveSessionHolder
         @Inject lateinit var homeActivityViewModelFactory: HomeActivityViewModel.Factory
         @Inject lateinit var homeNavigator: HomeNavigator
    +    @Inject lateinit var navigator: Navigator
         @Inject lateinit var vectorUncaughtExceptionHandler: VectorUncaughtExceptionHandler
         @Inject lateinit var pushManager: PushersManager
         @Inject lateinit var notificationDrawerManager: NotificationDrawerManager
    @@ -192,6 +194,10 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
                     bugReporter.openBugReportScreen(this, false)
                     return true
                 }
    +            R.id.menu_home_filter -> {
    +                navigator.openRoomsFiltering(this)
    +                return true
    +            }
             }
     
             return true
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    index db8ae359..bd4b2ca4 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    @@ -209,7 +209,7 @@ class HomeDetailFragment : VectorBaseFragment(), KeysBackupBanner.Delegate {
             unreadCounterBadgeViews[INDEX_PEOPLE].render(UnreadCounterBadgeView.State(it.notificationCountPeople, it.notificationHighlightPeople))
             unreadCounterBadgeViews[INDEX_ROOMS].render(UnreadCounterBadgeView.State(it.notificationCountRooms, it.notificationHighlightRooms))
             syncProgressBar.visibility = when (it.syncState) {
    -            is SyncState.RUNNING -> if (it.syncState.catchingUp) View.VISIBLE else View.GONE
    +            is SyncState.RUNNING -> if (it.syncState.afterPause) View.VISIBLE else View.GONE
                 else                 -> View.GONE
             }
             syncProgressBarWrap.visibility = syncProgressBar.visibility
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt
    index 832e8a5e..ac4cc08d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt
    @@ -52,7 +52,7 @@ class HomeDrawerFragment : VectorBaseFragment() {
                 replaceChildFragment(groupListFragment, R.id.homeDrawerGroupListContainer)
             }
     
    -        session.observeUser(session.sessionParams.credentials.userId).observeK(this) { user ->
    +        session.observeUser(session.myUserId).observeK(this) { user ->
                 if (user != null) {
                     avatarRenderer.render(user.avatarUrl, user.userId, user.displayName, homeDrawerHeaderAvatarView)
                     homeDrawerUsernameView.text = user.displayName
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeRoomListObservableStore.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeRoomListObservableStore.kt
    index 4e0b5b70..df8cd411 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeRoomListObservableStore.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeRoomListObservableStore.kt
    @@ -18,10 +18,6 @@ package im.vector.riotx.features.home
     
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.riotx.core.utils.RxStore
    -import im.vector.riotx.features.home.room.list.RoomListDisplayModeFilter
    -import im.vector.riotx.features.home.room.list.RoomListFragment
    -import io.reactivex.Observable
    -import io.reactivex.schedulers.Schedulers
     import javax.inject.Inject
     import javax.inject.Singleton
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt
    index 229652b0..513379bd 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt
    @@ -93,7 +93,7 @@ class GroupListViewModel @AssistedInject constructor(@Assisted initialState: Gro
                     .rx()
                     .liveGroupSummaries()
                     .map {
    -                    val myUser = session.getUser(session.sessionParams.credentials.userId)
    +                    val myUser = session.getUser(session.myUserId)
                         val allCommunityGroup = GroupSummary(
                                 groupId = ALL_COMMUNITIES_GROUP_ID,
                                 displayName = stringProvider.getString(R.string.group_all_communities),
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt
    index d52b16ca..ace0802e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt
    @@ -32,7 +32,6 @@ sealed class RoomDetailActions {
         data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailActions()
         data class UndoReaction(val targetEventId: String, val key: String, val reason: String? = "") : RoomDetailActions()
         data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions()
    -    data class ShowEditHistoryAction(val event: String, val editAggregatedSummary: EditAggregatedSummary) : RoomDetailActions()
         data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions()
         data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions()
         object AcceptInvite : RoomDetailActions()
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt
    index 9108f5d0..6ad9a61f 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt
    @@ -35,7 +35,7 @@ class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {
             super.onCreate(savedInstanceState)
             if (isFirstCreation()) {
                 val roomDetailArgs: RoomDetailArgs = intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
    -                                                 ?: return
    +                    ?: return
                 val roomDetailFragment = RoomDetailFragment.newInstance(roomDetailArgs)
                 replaceFragment(roomDetailFragment, R.id.roomDetailContainer)
             }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    index f611fe99..b7491ae6 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    @@ -37,9 +37,11 @@ import androidx.annotation.DrawableRes
     import androidx.appcompat.app.AlertDialog
     import androidx.core.content.ContextCompat
     import androidx.lifecycle.ViewModelProviders
    +import androidx.recyclerview.widget.ItemTouchHelper
     import androidx.recyclerview.widget.LinearLayoutManager
     import androidx.recyclerview.widget.RecyclerView
     import butterknife.BindView
    +import com.airbnb.epoxy.EpoxyModel
     import com.airbnb.epoxy.EpoxyVisibilityTracker
     import com.airbnb.mvrx.args
     import com.airbnb.mvrx.fragmentViewModel
    @@ -57,8 +59,10 @@ import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.model.message.*
    +import im.vector.matrix.android.api.session.room.send.SendState
     import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
     import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
    +import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
     import im.vector.matrix.android.api.session.user.model.User
     import im.vector.riotx.R
     import im.vector.riotx.core.di.ScreenComponent
    @@ -85,12 +89,9 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerView
     import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel
     import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState
     import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
    -import im.vector.riotx.features.home.room.detail.timeline.action.ActionsHandler
    -import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet
    -import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuViewModel
    -import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet
    +import im.vector.riotx.features.home.room.detail.timeline.action.*
     import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
    -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
    +import im.vector.riotx.features.home.room.detail.timeline.item.*
     import im.vector.riotx.features.html.EventHtmlRenderer
     import im.vector.riotx.features.html.PillImageSpan
     import im.vector.riotx.features.invite.VectorInviteView
    @@ -261,7 +262,7 @@ class RoomDetailFragment :
             composerLayout.composerRelatedMessageContent.text = formattedBody
                     ?: nonFormattedBody
     
    -        composerLayout.composerEditText.setText(if (useText) nonFormattedBody else "")
    +        composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
             composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
     
             avatarRenderer.render(event.senderAvatar, event.root.senderId
    @@ -269,8 +270,10 @@ class RoomDetailFragment :
     
             composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
             composerLayout.expand {
    +            //need to do it here also when not using quick reply
                 focusComposerAndShowKeyboard()
             }
    +        focusComposerAndShowKeyboard()
         }
     
         override fun onResume() {
    @@ -326,6 +329,32 @@ class RoomDetailFragment :
                     })
             recyclerView.setController(timelineEventController)
             timelineEventController.callback = this
    +
    +        if (VectorPreferences.swipeToReplyIsEnabled(requireContext())) {
    +            val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
    +                    R.drawable.ic_reply,
    +                    object : RoomMessageTouchHelperCallback.QuickReplayHandler {
    +                        override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
    +                            (model as? AbsMessageItem)?.informationData?.let {
    +                                val eventId = it.eventId
    +                                roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
    +                            }
    +                        }
    +
    +                        override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
    +                            return when (model) {
    +                                is MessageFileItem,
    +                                is MessageImageVideoItem,
    +                                is MessageTextItem -> {
    +                                    return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
    +                                }
    +                                else               -> false
    +                            }
    +                        }
    +                    })
    +            val touchHelper = ItemTouchHelper(swipeCallback)
    +            touchHelper.attachToRecyclerView(recyclerView)
    +        }
         }
     
         private fun setupComposer() {
    @@ -489,7 +518,7 @@ class RoomDetailFragment :
                 timelineEventController.setTimeline(state.timeline, state.eventId)
                 inviteView.visibility = View.GONE
     
    -            val uid = session.sessionParams.credentials.userId
    +            val uid = session.myUserId
                 val meMember = session.getRoom(state.roomId)?.getRoomMember(uid)
                 avatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView)
     
    @@ -575,7 +604,7 @@ class RoomDetailFragment :
     
         override fun onUrlLongClicked(url: String): Boolean {
             // Copy the url to the clipboard
    -        copyToClipboard(requireContext(), url)
    +        copyToClipboard(requireContext(), url, true, R.string.link_copied_to_clipboard)
             return true
         }
     
    @@ -666,10 +695,8 @@ class RoomDetailFragment :
         }
     
         override fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?) {
    -        editAggregatedSummary?.also {
    -            roomDetailViewModel.process(RoomDetailActions.ShowEditHistoryAction(informationData.eventId, it))
    -        }
    -
    +        ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData)
    +                .show(requireActivity().supportFragmentManager, "DISPLAY_EDITS")
         }
     // AutocompleteUserPresenter.Callback
     
    @@ -785,7 +812,7 @@ class RoomDetailFragment :
             if (null != text) {
     //            var vibrate = false
     
    -            val myDisplayName = session.getUser(session.sessionParams.credentials.userId)?.displayName
    +            val myDisplayName = session.getUser(session.myUserId)?.displayName
                 if (TextUtils.equals(myDisplayName, text)) {
                     // current user
                     if (TextUtils.isEmpty(composerLayout.composerEditText.text)) {
    @@ -833,10 +860,12 @@ class RoomDetailFragment :
         // VectorInviteView.Callback
     
         override fun onAcceptInvite() {
    +        notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
             roomDetailViewModel.process(RoomDetailActions.AcceptInvite)
         }
     
         override fun onRejectInvite() {
    +        notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
             roomDetailViewModel.process(RoomDetailActions.RejectInvite)
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt
    index 2a19914a..d38561fa 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt
    @@ -38,8 +38,8 @@ import im.vector.matrix.android.api.session.room.model.message.MessageType
     import im.vector.matrix.android.api.session.room.model.message.getFileUrl
     import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
     import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
    +import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
     import im.vector.matrix.rx.rx
    -import im.vector.riotx.R
     import im.vector.riotx.core.intent.getFilenameFromUri
     import im.vector.riotx.core.platform.VectorViewModel
     import im.vector.riotx.core.resources.UserPreferencesProvider
    @@ -52,8 +52,6 @@ import org.commonmark.parser.Parser
     import org.commonmark.renderer.html.HtmlRenderer
     import timber.log.Timber
     import java.io.File
    -import java.text.SimpleDateFormat
    -import java.util.*
     import java.util.concurrent.TimeUnit
     
     
    @@ -97,7 +95,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
             observeRoomSummary()
             observeEventDisplayedActions()
             observeInvitationState()
    -        room.loadRoomMembersIfNeeded()
    +        cancelableBag += room.loadRoomMembersIfNeeded()
             timeline.start()
             setState { copy(timeline = this@RoomDetailViewModel.timeline) }
         }
    @@ -114,7 +112,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
                 is RoomDetailActions.RedactAction           -> handleRedactEvent(action)
                 is RoomDetailActions.UndoReaction           -> handleUndoReact(action)
                 is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action)
    -            is RoomDetailActions.ShowEditHistoryAction  -> handleShowEditHistoryReaction(action)
                 is RoomDetailActions.EnterEditMode          -> handleEditAction(action)
                 is RoomDetailActions.EnterQuoteMode         -> handleQuoteAction(action)
                 is RoomDetailActions.EnterReplyMode         -> handleReplyAction(action)
    @@ -230,16 +227,27 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
                         }
                     }
                     is SendMode.EDIT  -> {
    -                    val messageContent: MessageContent? =
    -                            state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
    -                                    ?: state.sendMode.timelineEvent.root.getClearContent().toModel()
    -                    val nonFormattedBody = messageContent?.body ?: ""
     
    -                    if (nonFormattedBody != action.text) {
    -                        room.editTextMessage(state.sendMode.timelineEvent.root.eventId
    -                                ?: "", messageContent?.type ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
    +                    //is original event a reply?
    +                    val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId
    +                            ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId
    +                    if (inReplyTo != null) {
    +                        //TODO check if same content?
    +                        room.getTimeLineEvent(inReplyTo)?.let {
    +                            room.editReply(state.sendMode.timelineEvent, it, action.text)
    +                        }
                         } else {
    -                        Timber.w("Same message content, do not send edition")
    +                        val messageContent: MessageContent? =
    +                                state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
    +                                        ?: state.sendMode.timelineEvent.root.getClearContent().toModel()
    +                        val existingBody = messageContent?.body ?: ""
    +                        if (existingBody != action.text) {
    +                            room.editTextMessage(state.sendMode.timelineEvent.root.eventId
    +                                    ?: "", messageContent?.type
    +                                    ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
    +                        } else {
    +                            Timber.w("Same message content, do not send edition")
    +                        }
                         }
                         setState {
                             copy(
    @@ -309,22 +317,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
             return finalText
         }
     
    -    private fun handleShowEditHistoryReaction(action: RoomDetailActions.ShowEditHistoryAction) {
    -        //TODO temporary implementation
    -        val lastReplace = action.editAggregatedSummary.sourceEvents.lastOrNull()?.let {
    -            room.getTimeLineEvent(it)
    -        } ?: return
    -
    -        val dateFormat = SimpleDateFormat("EEE, d MMM yyyy HH:mm", Locale.getDefault())
    -        _nonBlockingPopAlert.postValue(LiveEvent(
    -                Pair(R.string.last_edited_info_message, listOf(
    -                        lastReplace.getDisambiguatedDisplayName(),
    -                        dateFormat.format(Date(lastReplace.root.originServerTs ?: 0)))
    -                ))
    -        )
    -    }
    -
    -
         private fun handleChangeTopicSlashCommand(changeTopic: ParsedCommand.ChangeTopic) {
             _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
     
    @@ -364,7 +356,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
         }
     
         private fun handleUndoReact(action: RoomDetailActions.UndoReaction) {
    -        room.undoReaction(action.key, action.targetEventId, session.sessionParams.credentials.userId)
    +        room.undoReaction(action.key, action.targetEventId, session.myUserId)
         }
     
     
    @@ -372,7 +364,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
             if (action.add) {
                 room.sendReaction(action.selectedReaction, action.targetEventId)
             } else {
    -            room.undoReaction(action.selectedReaction, action.targetEventId, session.sessionParams.credentials.userId)
    +            room.undoReaction(action.selectedReaction, action.targetEventId, session.myUserId)
             }
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt
    new file mode 100644
    index 00000000..cb283511
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt
    @@ -0,0 +1,214 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + * http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +package im.vector.riotx.features.home.room.detail
    +
    +import android.annotation.SuppressLint
    +import android.content.Context
    +import android.graphics.Canvas
    +import android.graphics.drawable.Drawable
    +import android.util.TypedValue
    +import android.view.HapticFeedbackConstants
    +import android.view.MotionEvent
    +import android.view.View
    +import androidx.annotation.DrawableRes
    +import androidx.core.content.ContextCompat
    +import androidx.recyclerview.widget.ItemTouchHelper
    +import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_SWIPE
    +import androidx.recyclerview.widget.RecyclerView
    +import com.airbnb.epoxy.EpoxyModel
    +import com.airbnb.epoxy.EpoxyTouchHelperCallback
    +import com.airbnb.epoxy.EpoxyViewHolder
    +import timber.log.Timber
    +
    +
    +class RoomMessageTouchHelperCallback(private val context: Context,
    +                                     @DrawableRes actionIcon: Int,
    +                                     private val handler: QuickReplayHandler) : EpoxyTouchHelperCallback() {
    +
    +    interface QuickReplayHandler {
    +        fun performQuickReplyOnHolder(model: EpoxyModel<*>)
    +        fun canSwipeModel(model: EpoxyModel<*>): Boolean
    +    }
    +
    +    private var swipeBack: Boolean = false
    +    private var dX = 0f
    +    private var startTracking = false
    +    private var isVibrate = false
    +
    +    private var replyButtonProgress: Float = 0F
    +    private var lastReplyButtonAnimationTime: Long = 0
    +
    +    private var imageDrawable: Drawable = ContextCompat.getDrawable(context, actionIcon)!!
    +
    +
    +    private val triggerDistance = convertToPx(100)
    +    private val minShowDistance = convertToPx(20)
    +    private val triggerDelta = convertToPx(20)
    +
    +    override fun onSwiped(viewHolder: EpoxyViewHolder?, direction: Int) {
    +
    +    }
    +
    +    override fun onMove(recyclerView: RecyclerView?, viewHolder: EpoxyViewHolder?, target: EpoxyViewHolder?): Boolean {
    +        return false
    +    }
    +
    +    override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: EpoxyViewHolder): Int {
    +        if (handler.canSwipeModel(viewHolder.model)) {
    +            return ItemTouchHelper.Callback.makeMovementFlags(0, ItemTouchHelper.START) //Should we use Left?
    +        } else {
    +            return 0
    +        }
    +    }
    +
    +
    +    //We never let items completely go out
    +    override fun convertToAbsoluteDirection(flags: Int, layoutDirection: Int): Int {
    +        if (swipeBack) {
    +            swipeBack = false
    +            return 0
    +        }
    +        return super.convertToAbsoluteDirection(flags, layoutDirection)
    +    }
    +
    +    override fun onChildDraw(c: Canvas,
    +                             recyclerView: RecyclerView,
    +                             viewHolder: EpoxyViewHolder,
    +                             dX: Float,
    +                             dY: Float,
    +                             actionState: Int,
    +                             isCurrentlyActive: Boolean) {
    +        if (actionState == ACTION_STATE_SWIPE) {
    +            setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
    +        }
    +        val size = triggerDistance
    +        if (Math.abs(viewHolder.itemView.translationX) < size || dX > this.dX /*going back*/) {
    +            super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
    +            this.dX = dX
    +            startTracking = true
    +        }
    +        drawReplyButton(c, viewHolder.itemView)
    +    }
    +
    +
    +    @SuppressLint("ClickableViewAccessibility")
    +    private fun setTouchListener(c: Canvas,
    +                                 recyclerView: RecyclerView,
    +                                 viewHolder: EpoxyViewHolder,
    +                                 dX: Float,
    +                                 dY: Float,
    +                                 actionState: Int,
    +                                 isCurrentlyActive: Boolean) {
    +        //TODO can this interfer with other interactions? should i remove it
    +        recyclerView.setOnTouchListener { v, event ->
    +            swipeBack = event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP
    +            if (swipeBack) {
    +                if (Math.abs(dX) >= triggerDistance) {
    +                    try {
    +                        viewHolder.model?.let { handler.performQuickReplyOnHolder(it) }
    +                    } catch (e: IllegalStateException) {
    +                        Timber.e(e)
    +                    }
    +                }
    +            }
    +            false
    +        }
    +    }
    +
    +
    +    private fun drawReplyButton(canvas: Canvas, itemView: View) {
    +
    +        Timber.v("drawReplyButton")
    +        val translationX = Math.abs(itemView.translationX)
    +        val newTime = System.currentTimeMillis()
    +        val dt = Math.min(17, newTime - lastReplyButtonAnimationTime)
    +        lastReplyButtonAnimationTime = newTime
    +        val showing = translationX >= minShowDistance
    +        if (showing) {
    +            if (replyButtonProgress < 1.0f) {
    +                replyButtonProgress += dt / 180.0f
    +                if (replyButtonProgress > 1.0f) {
    +                    replyButtonProgress = 1.0f
    +                } else {
    +                    itemView.invalidate()
    +                }
    +            }
    +        } else if (translationX <= 0.0f) {
    +            replyButtonProgress = 0f
    +            startTracking = false
    +            isVibrate = false
    +        } else {
    +            if (replyButtonProgress > 0.0f) {
    +                replyButtonProgress -= dt / 180.0f
    +                if (replyButtonProgress < 0.1f) {
    +                    replyButtonProgress = 0f
    +                } else {
    +                    itemView.invalidate()
    +                }
    +            }
    +        }
    +        val alpha: Int
    +        val scale: Float
    +        if (showing) {
    +            scale = if (replyButtonProgress <= 0.8f) {
    +                1.2f * (replyButtonProgress / 0.8f)
    +            } else {
    +                1.2f - 0.2f * ((replyButtonProgress - 0.8f) / 0.2f)
    +            }
    +            alpha = Math.min(255f, 255 * (replyButtonProgress / 0.8f)).toInt()
    +        } else {
    +            scale = replyButtonProgress
    +            alpha = Math.min(255f, 255 * replyButtonProgress).toInt()
    +        }
    +
    +        imageDrawable.alpha = alpha
    +        if (startTracking) {
    +            if (!isVibrate && translationX >= triggerDistance) {
    +                itemView.performHapticFeedback(
    +                        HapticFeedbackConstants.LONG_PRESS
    +//                        , HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
    +                )
    +                isVibrate = true
    +            }
    +        }
    +
    +        val x: Int = itemView.width - if (translationX > triggerDistance + triggerDelta) {
    +            (convertToPx(130) / 2).toInt()
    +        } else {
    +            (translationX / 2).toInt()
    +        }
    +
    +        val y = (itemView.top + itemView.measuredHeight / 2).toFloat()
    +        //magic numbers?
    +        imageDrawable.setBounds(
    +                (x - convertToPx(12) * scale).toInt(),
    +                (y - convertToPx(11) * scale).toInt(),
    +                (x + convertToPx(12) * scale).toInt(),
    +                (y + convertToPx(10) * scale).toInt()
    +        )
    +        imageDrawable.draw(canvas)
    +        imageDrawable.alpha = 255
    +    }
    +
    +    private fun convertToPx(dp: Int): Float {
    +        return TypedValue.applyDimension(
    +                TypedValue.COMPLEX_UNIT_DIP,
    +                dp.toFloat(),
    +                context.resources.displayMetrics
    +        )
    +    }
    +
    +}
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt
    index 488ecbd3..4a3fc3ff 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt
    @@ -57,6 +57,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
     
         var currentConstraintSetId: Int = -1
     
    +    private val animationDuration = 100L
     
         init {
             inflate(context, R.layout.merge_composer_layout, this)
    @@ -73,7 +74,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
             currentConstraintSetId = R.layout.constraint_set_composer_layout_compact
             if (animate) {
                 val transition = AutoTransition()
    -//            transition.duration = 5000
    +            transition.duration = animationDuration
                 transition.addListener(object : Transition.TransitionListener {
     
                     override fun onTransitionEnd(transition: Transition) {
    @@ -105,7 +106,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
             currentConstraintSetId = R.layout.constraint_set_composer_layout_expanded
             if (animate) {
                 val transition = AutoTransition()
    -//            transition.duration = 5000
    +            transition.duration = animationDuration
                 transition.addListener(object : Transition.TransitionListener {
     
                     override fun onTransitionEnd(transition: Transition) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt
    index b6cf00a3..0b78f815 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt
    @@ -18,7 +18,6 @@ package im.vector.riotx.features.home.room.detail.timeline
     
     import android.os.Handler
     import android.os.Looper
    -import android.util.LongSparseArray
     import android.view.View
     import androidx.recyclerview.widget.DiffUtil
     import androidx.recyclerview.widget.ListUpdateCallback
    @@ -84,7 +83,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
         }
     
         private val collapsedEventIds = linkedSetOf()
    -    private val mergeItemCollapseStates = HashMap()
    +    private val mergeItemCollapseStates = HashMap()
         private val modelCache = arrayListOf()
     
         private var currentSnapshot: List = emptyList()
    @@ -178,16 +177,19 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
         }
     
         override fun buildModels() {
    -        LoadingItem_()
    +        val loaderAdded = LoadingItem_()
                     .id("forward_loading_item")
                     .addWhen(Timeline.Direction.FORWARDS)
     
             val timelineModels = getModels()
             add(timelineModels)
     
    -        LoadingItem_()
    -                .id("backward_loading_item")
    -                .addWhen(Timeline.Direction.BACKWARDS)
    +        // Avoid displaying two loaders if there is no elements between them
    +        if (!loaderAdded || timelineModels.isNotEmpty()) {
    +            LoadingItem_()
    +                    .id("backward_loading_item")
    +                    .addWhen(Timeline.Direction.BACKWARDS)
    +        }
         }
     
         // Timeline.LISTENER ***************************************************************************
    @@ -310,9 +312,13 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
             }
         }
     
    -    private fun LoadingItem_.addWhen(direction: Timeline.Direction) {
    +    /**
    +     * Return true if added
    +     */
    +    private fun LoadingItem_.addWhen(direction: Timeline.Direction): Boolean {
             val shouldAdd = timeline?.hasMoreToLoad(direction) ?: false
             addIf(shouldAdd, this@TimelineEventController)
    +        return shouldAdd
         }
     
         fun searchPositionOfEvent(eventId: String): Int? {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
    index 21f5da52..5b0dbdfe 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
    @@ -126,51 +126,55 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
                     }
                     //TODO is downloading attachement?
     
    -                if (event.canReact()) {
    -                    this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, eventId))
    -                }
    -                if (canCopy(type)) {
    -                    //TODO copy images? html? see ClipBoard
    -                    this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent!!.body))
    -                }
    +                if (!event.root.isRedacted()) {
     
    -                if (canReply(event, messageContent)) {
    -                    this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, eventId))
    -                }
    -
    -                if (canEdit(event, session.sessionParams.credentials.userId)) {
    -                    this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, eventId))
    -                }
    -
    -                if (canRedact(event, session.sessionParams.credentials.userId)) {
    -                    this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, eventId))
    -                }
    -
    -                if (canQuote(event, messageContent)) {
    -                    this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, eventId))
    -                }
    -
    -                if (canViewReactions(event)) {
    -                    this.add(SimpleAction(ACTION_VIEW_REACTIONS, R.string.message_view_reaction, R.drawable.ic_view_reactions, informationData))
    -                }
    -
    -                if (canShare(type)) {
    -                    if (messageContent is MessageImageContent) {
    -                        this.add(
    -                                SimpleAction(ACTION_SHARE,
    -                                        R.string.share, R.drawable.ic_share,
    -                                        session.contentUrlResolver().resolveFullSize(messageContent.url))
    -                        )
    +                    if (canReply(event, messageContent)) {
    +                        this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, eventId))
    +                    }
    +
    +                    if (canEdit(event, session.myUserId)) {
    +                        this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, eventId))
    +                    }
    +
    +                    if (canRedact(event, session.myUserId)) {
    +                        this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, eventId))
    +                    }
    +
    +                    if (canCopy(type)) {
    +                        //TODO copy images? html? see ClipBoard
    +                        this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent!!.body))
    +                    }
    +
    +                    if (event.canReact()) {
    +                        this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, eventId))
    +                    }
    +
    +                    if (canQuote(event, messageContent)) {
    +                        this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, eventId))
    +                    }
    +
    +                    if (canViewReactions(event)) {
    +                        this.add(SimpleAction(ACTION_VIEW_REACTIONS, R.string.message_view_reaction, R.drawable.ic_view_reactions, informationData))
    +                    }
    +
    +                    if (canShare(type)) {
    +                        if (messageContent is MessageImageContent) {
    +                            this.add(
    +                                    SimpleAction(ACTION_SHARE,
    +                                            R.string.share, R.drawable.ic_share,
    +                                            session.contentUrlResolver().resolveFullSize(messageContent.url))
    +                            )
    +                        }
    +                        //TODO
                         }
    -                    //TODO
    -                }
     
     
    -                if (event.sendState == SendState.SENT) {
    +                    if (event.sendState == SendState.SENT) {
     
    -                    //TODO Can be redacted
    +                        //TODO Can be redacted
     
    -                    //TODO sent by me or sufficient power level
    +                        //TODO sent by me or sufficient power level
    +                    }
                     }
     
                     this.add(SimpleAction(VIEW_SOURCE, R.string.view_source, R.drawable.ic_view_source, event.root.toContentStringWithIndent()))
    @@ -181,7 +185,7 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
                     }
                     this.add(SimpleAction(ACTION_COPY_PERMALINK, R.string.permalink, R.drawable.ic_permalink, event.root.eventId))
     
    -                if (session.sessionParams.credentials.userId != event.root.senderId && event.root.getClearType() == EventType.MESSAGE) {
    +                if (session.myUserId != event.root.senderId && event.root.getClearType() == EventType.MESSAGE) {
                         //not sent by me
                         this.add(SimpleAction(ACTION_FLAG, R.string.report_content, R.drawable.ic_flag, event.root.eventId))
                     }
    @@ -240,7 +244,7 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
             //Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
             if (event.root.getClearType() != EventType.MESSAGE) return false
             //TODO if user is admin or moderator
    -        val messageContent = event.root.content.toModel()
    +        val messageContent = event.root.getClearContent().toModel()
             return event.root.senderId == myUserId && (
                     messageContent?.type == MessageType.MSGTYPE_TEXT
                             || messageContent?.type == MessageType.MSGTYPE_EMOTE
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryBottomSheet.kt
    new file mode 100644
    index 00000000..aefbde43
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryBottomSheet.kt
    @@ -0,0 +1,93 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + * http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +package im.vector.riotx.features.home.room.detail.timeline.action
    +
    +import android.os.Bundle
    +import android.view.LayoutInflater
    +import android.view.View
    +import android.view.ViewGroup
    +import android.widget.LinearLayout
    +import androidx.recyclerview.widget.DividerItemDecoration
    +import butterknife.BindView
    +import butterknife.ButterKnife
    +import com.airbnb.epoxy.EpoxyRecyclerView
    +import com.airbnb.mvrx.MvRx
    +import com.airbnb.mvrx.fragmentViewModel
    +import com.airbnb.mvrx.withState
    +import im.vector.riotx.R
    +import im.vector.riotx.core.di.ScreenComponent
    +import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
    +import im.vector.riotx.features.html.EventHtmlRenderer
    +import kotlinx.android.synthetic.main.bottom_sheet_epoxylist_with_title.*
    +import javax.inject.Inject
    +
    +
    +/**
    + * Bottom sheet displaying list of edits for a given event ordered by timestamp
    + */
    +class ViewEditHistoryBottomSheet : VectorBaseBottomSheetDialogFragment() {
    +
    +    private val viewModel: ViewEditHistoryViewModel by fragmentViewModel(ViewEditHistoryViewModel::class)
    +
    +    @Inject lateinit var viewEditHistoryViewModelFactory: ViewEditHistoryViewModel.Factory
    +    @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer
    +
    +    @BindView(R.id.bottom_sheet_display_reactions_list)
    +    lateinit var epoxyRecyclerView: EpoxyRecyclerView
    +
    +    private val epoxyController by lazy {
    +        ViewEditHistoryEpoxyController(requireContext(), viewModel.timelineDateFormatter, eventHtmlRenderer)
    +    }
    +
    +    override fun injectWith(screenComponent: ScreenComponent) {
    +        screenComponent.inject(this)
    +    }
    +
    +    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    +        val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
    +        ButterKnife.bind(this, view)
    +        return view
    +    }
    +
    +    override fun onActivityCreated(savedInstanceState: Bundle?) {
    +        super.onActivityCreated(savedInstanceState)
    +        epoxyRecyclerView.setController(epoxyController)
    +        val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context,
    +                LinearLayout.VERTICAL)
    +        epoxyRecyclerView.addItemDecoration(dividerItemDecoration)
    +        bottomSheetTitle.text = context?.getString(R.string.message_edits)
    +    }
    +
    +
    +    override fun invalidate() = withState(viewModel) {
    +        epoxyController.setData(it)
    +    }
    +
    +    companion object {
    +        fun newInstance(roomId: String, informationData: MessageInformationData): ViewEditHistoryBottomSheet {
    +            val args = Bundle()
    +            val parcelableArgs = TimelineEventFragmentArgs(
    +                    informationData.eventId,
    +                    roomId,
    +                    informationData
    +            )
    +            args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
    +            return ViewEditHistoryBottomSheet().apply { arguments = args }
    +
    +        }
    +    }
    +}
    +
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryEpoxyController.kt
    new file mode 100644
    index 00000000..fc11f255
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryEpoxyController.kt
    @@ -0,0 +1,157 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + * http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +package im.vector.riotx.features.home.room.detail.timeline.action
    +
    +import android.content.Context
    +import android.text.Spannable
    +import android.text.format.DateUtils
    +import androidx.core.content.ContextCompat
    +import com.airbnb.epoxy.TypedEpoxyController
    +import com.airbnb.mvrx.Fail
    +import com.airbnb.mvrx.Incomplete
    +import com.airbnb.mvrx.Success
    +import im.vector.matrix.android.api.session.events.model.Event
    +import im.vector.matrix.android.api.session.events.model.toModel
    +import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
    +import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply
    +import im.vector.riotx.R
    +import im.vector.riotx.core.extensions.localDateTime
    +import im.vector.riotx.core.ui.list.genericFooterItem
    +import im.vector.riotx.core.ui.list.genericItem
    +import im.vector.riotx.core.ui.list.genericItemHeader
    +import im.vector.riotx.core.ui.list.genericLoaderItem
    +import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter
    +import im.vector.riotx.features.html.EventHtmlRenderer
    +import me.gujun.android.span.span
    +import name.fraser.neil.plaintext.diff_match_patch
    +import timber.log.Timber
    +import java.util.*
    +
    +/**
    + * Epoxy controller for reaction event list
    + */
    +class ViewEditHistoryEpoxyController(private val context: Context,
    +                                     val timelineDateFormatter: TimelineDateFormatter,
    +                                     val eventHtmlRenderer: EventHtmlRenderer) : TypedEpoxyController() {
    +
    +    override fun buildModels(state: ViewEditHistoryViewState) {
    +        when (state.editList) {
    +            is Incomplete -> {
    +                genericLoaderItem {
    +                    id("Spinner")
    +                }
    +            }
    +            is Fail       -> {
    +                genericFooterItem {
    +                    id("failure")
    +                    text(context.getString(R.string.unknown_error))
    +                }
    +            }
    +            is Success    -> {
    +                state.editList()?.let { renderEvents(it, state.isOriginalAReply) }
    +            }
    +
    +        }
    +    }
    +
    +    private fun renderEvents(sourceEvents: List, isOriginalReply: Boolean) {
    +        if (sourceEvents.isEmpty()) {
    +            genericItem {
    +                id("footer")
    +                title(context.getString(R.string.no_message_edits_found))
    +            }
    +        } else {
    +            var lastDate: Calendar? = null
    +            sourceEvents.forEachIndexed { index, timelineEvent ->
    +
    +                val evDate = Calendar.getInstance().apply {
    +                    timeInMillis = timelineEvent.originServerTs
    +                            ?: System.currentTimeMillis()
    +                }
    +                if (lastDate?.get(Calendar.DAY_OF_YEAR) != evDate.get(Calendar.DAY_OF_YEAR)) {
    +                    //need to display header with day
    +                    val dateString = if (DateUtils.isToday(evDate.timeInMillis)) context.getString(R.string.today)
    +                    else timelineDateFormatter.formatMessageDay(timelineEvent.localDateTime())
    +                    genericItemHeader {
    +                        id(evDate.hashCode())
    +                        text(dateString)
    +                    }
    +                }
    +                lastDate = evDate
    +                val cContent = getCorrectContent(timelineEvent, isOriginalReply)
    +                val body = cContent.second?.let { eventHtmlRenderer.render(it) }
    +                        ?: cContent.first
    +
    +                val nextEvent = if (index + 1 <= sourceEvents.lastIndex) sourceEvents[index + 1] else null
    +
    +                var spannedDiff: Spannable? = null
    +                if (nextEvent != null && cContent.second == null /*No diff for html*/) {
    +                    //compares the body
    +                    val nContent = getCorrectContent(nextEvent, isOriginalReply)
    +                    val nextBody = nContent.second?.let { eventHtmlRenderer.render(it) }
    +                            ?: nContent.first
    +                    val dmp = diff_match_patch()
    +                    val diff = dmp.diff_main(nextBody.toString(), body.toString())
    +                    Timber.e("#### Diff: $diff")
    +                    dmp.diff_cleanupSemantic(diff)
    +                    Timber.e("#### Diff: $diff")
    +                    spannedDiff = span {
    +                        diff.map {
    +                            when (it.operation) {
    +                                diff_match_patch.Operation.DELETE -> {
    +                                    span {
    +                                        text = it.text
    +                                        textColor = ContextCompat.getColor(context, R.color.vector_error_color)
    +                                        textDecorationLine = "line-through"
    +                                    }
    +                                }
    +                                diff_match_patch.Operation.INSERT -> {
    +                                    span {
    +                                        text = it.text
    +                                        textColor = ContextCompat.getColor(context, R.color.vector_success_color)
    +                                    }
    +                                }
    +                                else                              -> {
    +                                    span {
    +                                        text = it.text
    +                                    }
    +                                }
    +                            }
    +                        }
    +
    +                    }
    +                }
    +                genericItem {
    +                    id(timelineEvent.eventId)
    +                    title(timelineDateFormatter.formatMessageHour(timelineEvent.localDateTime()))
    +                    description(spannedDiff ?: body)
    +                }
    +            }
    +        }
    +    }
    +
    +    private fun getCorrectContent(event: Event, isOriginalReply: Boolean): Pair {
    +        val clearContent = event.getClearContent().toModel()
    +        val newContent = clearContent
    +                ?.newContent
    +                ?.toModel()
    +        if (isOriginalReply) {
    +            return extractUsefulTextFromReply(newContent?.body ?: clearContent?.body ?: "") to null
    +        }
    +        return (newContent?.body ?: clearContent?.body ?: "") to (newContent?.formattedBody
    +                ?: clearContent?.formattedBody)
    +    }
    +}
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryViewModel.kt
    new file mode 100644
    index 00000000..6ad17210
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryViewModel.kt
    @@ -0,0 +1,123 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + * http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +package im.vector.riotx.features.home.room.detail.timeline.action
    +
    +import com.airbnb.mvrx.*
    +import com.squareup.inject.assisted.Assisted
    +import com.squareup.inject.assisted.AssistedInject
    +import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.crypto.MXCryptoError
    +import im.vector.matrix.android.api.session.events.model.Event
    +import im.vector.matrix.android.api.session.events.model.toModel
    +import im.vector.matrix.android.api.session.room.model.message.MessageContent
    +import im.vector.matrix.android.api.session.room.model.message.isReply
    +import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
    +import im.vector.riotx.core.platform.VectorViewModel
    +import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter
    +import timber.log.Timber
    +import java.util.*
    +
    +
    +data class ViewEditHistoryViewState(
    +        val eventId: String,
    +        val roomId: String,
    +        val isOriginalAReply: Boolean = false,
    +        val editList: Async> = Uninitialized)
    +    : MvRxState {
    +
    +    constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId)
    +
    +}
    +
    +class ViewEditHistoryViewModel @AssistedInject constructor(@Assisted
    +                                                           initialState: ViewEditHistoryViewState,
    +                                                           val session: Session,
    +                                                           val timelineDateFormatter: TimelineDateFormatter
    +) : VectorViewModel(initialState) {
    +
    +    private val roomId = initialState.roomId
    +    private val eventId = initialState.eventId
    +    private val room = session.getRoom(roomId)
    +            ?: throw IllegalStateException("Shouldn't use this ViewModel without a room")
    +
    +    @AssistedInject.Factory
    +    interface Factory {
    +        fun create(initialState: ViewEditHistoryViewState): ViewEditHistoryViewModel
    +    }
    +
    +
    +    companion object : MvRxViewModelFactory {
    +
    +        override fun create(viewModelContext: ViewModelContext, state: ViewEditHistoryViewState): ViewEditHistoryViewModel? {
    +            val fragment: ViewEditHistoryBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
    +            return fragment.viewEditHistoryViewModelFactory.create(state)
    +        }
    +
    +    }
    +
    +    init {
    +        loadHistory()
    +    }
    +
    +    private fun loadHistory() {
    +        setState { copy(editList = Loading()) }
    +        room.fetchEditHistory(eventId, object : MatrixCallback> {
    +            override fun onFailure(failure: Throwable) {
    +                setState {
    +                    copy(editList = Fail(failure))
    +                }
    +            }
    +
    +            override fun onSuccess(data: List) {
    +                var originalIsReply = false
    +
    +                val events = data.map { event ->
    +                    val timelineID = event.roomId + UUID.randomUUID().toString()
    +                    event.also {
    +                        //We need to check encryption
    +                        if (it.isEncrypted() && it.mxDecryptionResult == null) {
    +                            //for now decrypt sync
    +                            try {
    +                                val result = session.decryptEvent(it, timelineID)
    +                                it.mxDecryptionResult = OlmDecryptionResult(
    +                                        payload = result.clearEvent,
    +                                        senderKey = result.senderCurve25519Key,
    +                                        keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) },
    +                                        forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
    +                                )
    +                            } catch (e: MXCryptoError) {
    +                                Timber.w("Failed to decrypt event in history")
    +                            }
    +                        }
    +
    +                        if (event.eventId == it.eventId) {
    +                            originalIsReply = it.getClearContent().toModel().isReply()
    +                        }
    +                    }
    +
    +                }
    +                setState {
    +                    copy(
    +                            editList = Success(events),
    +                            isOriginalAReply = originalIsReply
    +                    )
    +                }
    +            }
    +        })
    +    }
    +
    +}
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt
    index 760b74da..d7e41784 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt
    @@ -21,7 +21,6 @@ import android.view.LayoutInflater
     import android.view.View
     import android.view.ViewGroup
     import android.widget.LinearLayout
    -import androidx.core.view.isVisible
     import androidx.recyclerview.widget.DividerItemDecoration
     import butterknife.BindView
     import butterknife.ButterKnife
    @@ -33,7 +32,7 @@ import im.vector.riotx.EmojiCompatFontProvider
     import im.vector.riotx.R
     import im.vector.riotx.core.di.ScreenComponent
     import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
    -import kotlinx.android.synthetic.main.bottom_sheet_display_reactions.*
    +import kotlinx.android.synthetic.main.bottom_sheet_epoxylist_with_title.*
     import javax.inject.Inject
     
     /**
    @@ -49,14 +48,16 @@ class ViewReactionBottomSheet : VectorBaseBottomSheetDialogFragment() {
         @BindView(R.id.bottom_sheet_display_reactions_list)
         lateinit var epoxyRecyclerView: EpoxyRecyclerView
     
    -    private val epoxyController by lazy { ViewReactionsEpoxyController(emojiCompatFontProvider.typeface) }
    +    private val epoxyController by lazy {
    +        ViewReactionsEpoxyController(requireContext(), emojiCompatFontProvider.typeface)
    +    }
     
         override fun injectWith(screenComponent: ScreenComponent) {
             screenComponent.inject(this)
         }
     
         override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    -        val view = inflater.inflate(R.layout.bottom_sheet_display_reactions, container, false)
    +        val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
             ButterKnife.bind(this, view)
             return view
         }
    @@ -67,16 +68,11 @@ class ViewReactionBottomSheet : VectorBaseBottomSheetDialogFragment() {
             val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context,
                     LinearLayout.VERTICAL)
             epoxyRecyclerView.addItemDecoration(dividerItemDecoration)
    +        bottomSheetTitle.text = context?.getString(R.string.reactions)
         }
     
     
         override fun invalidate() = withState(viewModel) {
    -        if (it.mapReactionKeyToMemberList() == null) {
    -            bottomSheetViewReactionSpinner.isVisible = true
    -            bottomSheetViewReactionSpinner.animate()
    -        } else {
    -            bottomSheetViewReactionSpinner.isVisible = false
    -        }
             epoxyController.setData(it)
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt
    index 57c3d265..74b3f492 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt
    @@ -16,24 +16,47 @@
     
     package im.vector.riotx.features.home.room.detail.timeline.action
     
    +import android.content.Context
     import android.graphics.Typeface
     import com.airbnb.epoxy.TypedEpoxyController
    +import com.airbnb.mvrx.Fail
    +import com.airbnb.mvrx.Incomplete
    +import com.airbnb.mvrx.Success
    +import im.vector.riotx.R
    +import im.vector.riotx.core.ui.list.genericFooterItem
    +import im.vector.riotx.core.ui.list.genericLoaderItem
     
     /**
      * Epoxy controller for reaction event list
      */
    -class ViewReactionsEpoxyController(private val emojiCompatTypeface: Typeface?) : TypedEpoxyController() {
    +class ViewReactionsEpoxyController(private val context: Context, private val emojiCompatTypeface: Typeface?)
    +    : TypedEpoxyController() {
     
         override fun buildModels(state: DisplayReactionsViewState) {
    -        val map = state.mapReactionKeyToMemberList() ?: return
    -        map.forEach {
    -            reactionInfoSimpleItem {
    -                id(it.eventId)
    -                emojiTypeFace(emojiCompatTypeface)
    -                timeStamp(it.timestamp)
    -                reactionKey(it.reactionKey)
    -                authorDisplayName(it.authorName ?: it.authorId)
    +        when (state.mapReactionKeyToMemberList) {
    +            is Incomplete -> {
    +                genericLoaderItem {
    +                    id("Spinner")
    +                }
    +            }
    +            is Fail       -> {
    +                genericFooterItem {
    +                    id("failure")
    +                    text(context.getString(R.string.unknown_error))
    +                }
    +            }
    +            is Success    -> {
    +                state.mapReactionKeyToMemberList()?.forEach {
    +                    reactionInfoSimpleItem {
    +                        id(it.eventId)
    +                        emojiTypeFace(emojiCompatTypeface)
    +                        timeStamp(it.timestamp)
    +                        reactionKey(it.reactionKey)
    +                        authorDisplayName(it.authorName ?: it.authorId)
    +                    }
    +                }
                 }
             }
    +
         }
     }
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt
    index 2a4a0c0f..080565cd 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt
    @@ -68,6 +68,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
                     return MessageTextItem_()
                             .message(spannableStr)
                             .avatarRenderer(avatarRenderer)
    +                        .colorProvider(colorProvider)
                             .informationData(informationData)
                             .highlighted(highlight)
                             .avatarCallback(callback)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    index d8f1c602..3a0d2d1d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    @@ -27,12 +27,14 @@ import dagger.Lazy
     import im.vector.matrix.android.api.permalinks.MatrixLinkify
     import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
     import im.vector.matrix.android.api.session.events.model.RelationType
    +import im.vector.matrix.android.api.session.events.model.toModel
     import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
     import im.vector.matrix.android.api.session.room.model.message.*
     import im.vector.matrix.android.api.session.room.send.SendState
     import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
     import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
     import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
    +import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
     import im.vector.riotx.EmojiCompatFontProvider
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -83,7 +85,9 @@ class MessageItemFactory @Inject constructor(
                             ?: //Malformed content, we should echo something on screen
                             return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))
     
    -        if (messageContent.relatesTo?.type == RelationType.REPLACE) {
    +        if (messageContent.relatesTo?.type == RelationType.REPLACE
    +                || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE
    +        ) {
                 // ignore replace event, the targeted id is already edited
                 return BlankItem_()
             }
    @@ -117,6 +121,7 @@ class MessageItemFactory @Inject constructor(
                                           callback: TimelineEventController.Callback?): MessageFileItem? {
             return MessageFileItem_()
                     .avatarRenderer(avatarRenderer)
    +                .colorProvider(colorProvider)
                     .informationData(informationData)
                     .highlighted(highlight)
                     .avatarCallback(callback)
    @@ -144,6 +149,7 @@ class MessageItemFactory @Inject constructor(
                                          callback: TimelineEventController.Callback?): MessageFileItem? {
             return MessageFileItem_()
                     .avatarRenderer(avatarRenderer)
    +                .colorProvider(colorProvider)
                     .informationData(informationData)
                     .highlighted(highlight)
                     .avatarCallback(callback)
    @@ -195,6 +201,7 @@ class MessageItemFactory @Inject constructor(
             )
             return MessageImageVideoItem_()
                     .avatarRenderer(avatarRenderer)
    +                .colorProvider(colorProvider)
                     .imageContentRenderer(imageContentRenderer)
                     .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
                     .playable(messageContent.info?.mimeType == "image/gif")
    @@ -226,7 +233,8 @@ class MessageItemFactory @Inject constructor(
             val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
             val thumbnailData = ImageContentRenderer.Data(
                     filename = messageContent.body,
    -                url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl,
    +                url = messageContent.videoInfo?.thumbnailFile?.url
    +                        ?: messageContent.videoInfo?.thumbnailUrl,
                     elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
                     height = messageContent.videoInfo?.height,
                     maxHeight = maxHeight,
    @@ -246,6 +254,7 @@ class MessageItemFactory @Inject constructor(
                     .imageContentRenderer(imageContentRenderer)
                     .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
                     .avatarRenderer(avatarRenderer)
    +                .colorProvider(colorProvider)
                     .playable(true)
                     .informationData(informationData)
                     .highlighted(highlight)
    @@ -288,6 +297,7 @@ class MessageItemFactory @Inject constructor(
                     }
                     .avatarRenderer(avatarRenderer)
                     .informationData(informationData)
    +                .colorProvider(colorProvider)
                     .highlighted(highlight)
                     .avatarCallback(callback)
                     .urlClickCallback(callback)
    @@ -353,6 +363,7 @@ class MessageItemFactory @Inject constructor(
             return MessageTextItem_()
                     .avatarRenderer(avatarRenderer)
                     .message(message)
    +                .colorProvider(colorProvider)
                     .informationData(informationData)
                     .highlighted(highlight)
                     .avatarCallback(callback)
    @@ -393,6 +404,7 @@ class MessageItemFactory @Inject constructor(
                         }
                     }
                     .avatarRenderer(avatarRenderer)
    +                .colorProvider(colorProvider)
                     .informationData(informationData)
                     .highlighted(highlight)
                     .avatarCallback(callback)
    @@ -414,9 +426,18 @@ class MessageItemFactory @Inject constructor(
                                       callback: TimelineEventController.Callback?): RedactedMessageItem? {
             return RedactedMessageItem_()
                     .avatarRenderer(avatarRenderer)
    +                .colorProvider(colorProvider)
                     .informationData(informationData)
                     .highlighted(highlight)
                     .avatarCallback(callback)
    +                .cellClickListener(
    +                        DebouncedClickListener(View.OnClickListener { view ->
    +                            callback?.onEventCellClicked(informationData, null, view)
    +                        }))
    +                .longClickListener { view ->
    +                    return@longClickListener callback?.onEventLongClicked(informationData, null, view)
    +                            ?: false
    +                }
         }
     
         private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt
    index 688cac3d..ca796667 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt
    @@ -23,12 +23,16 @@ import android.widget.ProgressBar
     import android.widget.TextView
     import androidx.core.view.isVisible
     import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
    +import im.vector.matrix.android.api.session.room.send.SendState
     import im.vector.riotx.R
     import im.vector.riotx.core.di.ActiveSessionHolder
    +import im.vector.riotx.core.resources.ColorProvider
     import im.vector.riotx.features.media.ImageContentRenderer
    +import im.vector.riotx.features.ui.getMessageTextColor
     import javax.inject.Inject
     
    -class ContentUploadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder) {
    +class ContentUploadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
    +                                                          private val colorProvider: ColorProvider) {
     
         private val updateListeners = mutableMapOf()
     
    @@ -38,7 +42,7 @@ class ContentUploadStateTrackerBinder @Inject constructor(private val activeSess
     
             activeSessionHolder.getActiveSession().also { session ->
                 val uploadStateTracker = session.contentUploadProgressTracker()
    -            val updateListener = ContentMediaProgressUpdater(progressLayout, mediaData)
    +            val updateListener = ContentMediaProgressUpdater(progressLayout, mediaData, colorProvider)
                 updateListeners[eventId] = updateListener
                 uploadStateTracker.track(eventId, updateListener)
             }
    @@ -56,7 +60,8 @@ class ContentUploadStateTrackerBinder @Inject constructor(private val activeSess
     }
     
     private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
    -                                          private val mediaData: ImageContentRenderer.Data) : ContentUploadStateTracker.UpdateListener {
    +                                          private val mediaData: ImageContentRenderer.Data,
    +                                          private val colorProvider: ColorProvider) : ContentUploadStateTracker.UpdateListener {
     
         override fun onUpdate(state: ContentUploadStateTracker.State) {
             when (state) {
    @@ -79,6 +84,7 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
                 progressBar?.isIndeterminate = true
                 progressBar?.progress = 0
                 progressTextView?.text = progressLayout.context.getString(R.string.send_file_step_idle)
    +            progressTextView?.setTextColor(colorProvider.getMessageTextColor(SendState.UNSENT))
             } else {
                 progressLayout.isVisible = false
             }
    @@ -106,6 +112,7 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
             val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView)
             progressBar?.isIndeterminate = true
             progressTextView?.text = progressLayout.context.getString(resId)
    +        progressTextView?.setTextColor(colorProvider.getMessageTextColor(SendState.ENCRYPTING))
         }
     
         private fun doHandleProgress(resId: Int, current: Long, total: Long) {
    @@ -119,6 +126,7 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
             progressTextView?.text = progressLayout.context.getString(resId,
                     Formatter.formatShortFileSize(progressLayout.context, current),
                     Formatter.formatShortFileSize(progressLayout.context, total))
    +        progressTextView?.setTextColor(colorProvider.getMessageTextColor(SendState.SENDING))
         }
     
         private fun handleFailure(state: ContentUploadStateTracker.State.Failure) {
    @@ -126,8 +134,8 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
             val progressBar = progressLayout.findViewById(R.id.mediaProgressBar)
             val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView)
             progressBar?.isVisible = false
    -        // TODO Red text
             progressTextView?.text = state.throwable.localizedMessage
    +        progressTextView?.setTextColor(colorProvider.getMessageTextColor(SendState.UNDELIVERED))
         }
     
         private fun handleSuccess(state: ContentUploadStateTracker.State.Success) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt
    index 7a66c6ad..88697db4 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt
    @@ -23,24 +23,32 @@ import android.view.ViewGroup
     import android.view.ViewStub
     import android.widget.ImageView
     import android.widget.TextView
    +import androidx.annotation.IdRes
     import androidx.constraintlayout.helper.widget.Flow
     import androidx.core.view.children
     import androidx.core.view.isGone
     import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import im.vector.riotx.R
    +import im.vector.riotx.core.resources.ColorProvider
     import im.vector.riotx.core.utils.DebouncedClickListener
     import im.vector.riotx.core.utils.DimensionUtils.dpToPx
     import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
     import im.vector.riotx.features.reactions.widget.ReactionButton
    +import im.vector.riotx.features.ui.getMessageTextColor
     
     
     abstract class AbsMessageItem : BaseEventItem() {
     
    -    abstract val informationData: MessageInformationData
    +    @EpoxyAttribute
    +    lateinit var informationData: MessageInformationData
     
    -    abstract val avatarRenderer: AvatarRenderer
    +    @EpoxyAttribute
    +    lateinit var avatarRenderer: AvatarRenderer
    +
    +    @EpoxyAttribute
    +    lateinit var colorProvider: ColorProvider
     
         @EpoxyAttribute
         var longClickListener: View.OnLongClickListener? = null
    @@ -153,13 +161,12 @@ abstract class AbsMessageItem : BaseEventItem() {
             return true
         }
     
    -    protected fun View.renderSendState() {
    -        isClickable = informationData.sendState.isSent()
    -        alpha = if (informationData.sendState.isSent()) 1f else 0.5f
    +    protected fun renderSendState(root: View, textView: TextView?) {
    +        root.isClickable = informationData.sendState.isSent()
    +        textView?.setTextColor(colorProvider.getMessageTextColor(informationData.sendState))
         }
     
    -    abstract class Holder : BaseHolder() {
    -
    +    abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) {
             val avatarImageView by bind(R.id.messageAvatarImageView)
             val memberNameView by bind(R.id.messageMemberNameView)
             val timeView by bind(R.id.messageTimeView)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt
    index 3a7d09e2..843f52b3 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt
    @@ -26,6 +26,9 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel
     import im.vector.riotx.core.platform.CheckableView
     import im.vector.riotx.core.utils.DimensionUtils.dpToPx
     
    +/**
    + * Children must override getViewType()
    + */
     abstract class BaseEventItem : VectorEpoxyModel() {
     
         var avatarStyle: AvatarStyle = AvatarStyle.SMALL
    @@ -43,31 +46,18 @@ abstract class BaseEventItem : VectorEpoxyModel
             holder.checkableBackground.isChecked = highlighted
         }
     
    -
    -    override fun getViewType(): Int {
    -        return getStubType()
    -    }
    -
    -    abstract fun getStubType(): Int
    -
    -
    -    abstract class BaseHolder : VectorEpoxyHolder() {
    -
    +    abstract class BaseHolder(@IdRes val stubId: Int) : VectorEpoxyHolder() {
             val leftGuideline by bind(R.id.messageStartGuideline)
             val checkableBackground by bind(R.id.messageSelectedBackground)
     
    -        @IdRes
    -        abstract fun getStubId(): Int
    -
             override fun bindView(itemView: View) {
                 super.bindView(itemView)
                 inflateStub()
             }
     
             private fun inflateStub() {
    -            view.findViewById(getStubId()).inflate()
    +            view.findViewById(stubId).inflate()
             }
    -
         }
     
         companion object {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt
    index cc50493a..0b30facf 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt
    @@ -31,11 +31,9 @@ abstract class DefaultItem : BaseEventItem() {
             holder.messageView.text = text
         }
     
    -    override fun getStubType(): Int = STUB_ID
    -
    -    class Holder : BaseHolder() {
    -        override fun getStubId(): Int = STUB_ID
    +    override fun getViewType() = STUB_ID
     
    +    class Holder : BaseHolder(STUB_ID) {
             val messageView by bind(R.id.stateMessageView)
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt
    index 0ad13fcf..4f26f9bb 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt
    @@ -46,7 +46,7 @@ data class MergedHeaderItem(private val isCollapsed: Boolean,
             return Holder()
         }
     
    -    override fun getStubType(): Int = STUB_ID
    +    override fun getViewType() = STUB_ID
     
         override fun bind(holder: Holder) {
             super.bind(holder)
    @@ -84,8 +84,7 @@ data class MergedHeaderItem(private val isCollapsed: Boolean,
                 val avatarUrl: String?
         )
     
    -    class Holder : BaseHolder() {
    -        override fun getStubId(): Int = STUB_ID
    +    class Holder : BaseHolder(STUB_ID) {
     
             val expandView by bind(R.id.itemMergedExpandTextView)
             val summaryView by bind(R.id.itemMergedSummaryTextView)
    @@ -95,6 +94,6 @@ data class MergedHeaderItem(private val isCollapsed: Boolean,
         }
     
         companion object {
    -        private val STUB_ID = R.id.messageContentMergedheaderStub
    +        private const val STUB_ID = R.id.messageContentMergedheaderStub
         }
     }
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt
    index 66b368df..45e57b59 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt
    @@ -25,7 +25,6 @@ import androidx.annotation.DrawableRes
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
    -import im.vector.riotx.features.home.AvatarRenderer
     
     @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
     abstract class MessageFileItem : AbsMessageItem() {
    @@ -36,34 +35,27 @@ abstract class MessageFileItem : AbsMessageItem() {
         @DrawableRes
         var iconRes: Int = 0
         @EpoxyAttribute
    -    override lateinit var informationData: MessageInformationData
    -    @EpoxyAttribute
    -    override lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute
         var clickListener: View.OnClickListener? = null
     
         override fun bind(holder: Holder) {
             super.bind(holder)
    -        holder.fileLayout.renderSendState()
    +        renderSendState(holder.fileLayout, holder.filenameView)
             holder.filenameView.text = filename
             holder.fileImageView.setImageResource(iconRes)
             holder.filenameView.setOnClickListener(clickListener)
             holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
         }
     
    -    override fun getStubType(): Int = STUB_ID
    -
    -    class Holder : AbsMessageItem.Holder() {
    -        override fun getStubId(): Int = STUB_ID
    +    override fun getViewType() = STUB_ID
     
    +    class Holder : AbsMessageItem.Holder(STUB_ID) {
             val fileLayout by bind(R.id.messageFileLayout)
             val fileImageView by bind(R.id.messageFileImageView)
             val filenameView by bind(R.id.messageFilenameView)
    -
         }
     
         companion object {
    -        private val STUB_ID = R.id.messageContentFileStub
    +        private const val STUB_ID = R.id.messageContentFileStub
         }
     
     }
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt
    index 67f0ed7b..d551e44c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt
    @@ -22,7 +22,6 @@ import android.widget.ImageView
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
    -import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
     import im.vector.riotx.features.media.ImageContentRenderer
     
    @@ -32,10 +31,6 @@ abstract class MessageImageVideoItem : AbsMessageItem(R.id.messageMediaUploadProgressLayout)
             val imageView by bind(R.id.messageThumbnailView)
             val playContentView by bind(R.id.messageMediaPlayView)
     
             val mediaContentView by bind(R.id.messageContentMedia)
    -
         }
     
    -
         companion object {
    -        private val STUB_ID = R.id.messageContentMediaStub
    +        private const val STUB_ID = R.id.messageContentMediaStub
         }
    -
     }
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt
    index 3ab428e0..fc867b12 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt
    @@ -16,6 +16,7 @@
     
     package im.vector.riotx.features.home.room.detail.timeline.item
     
    +import android.view.MotionEvent
     import androidx.appcompat.widget.AppCompatTextView
     import androidx.core.text.PrecomputedTextCompat
     import androidx.core.text.toSpannable
    @@ -24,7 +25,6 @@ import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
     import im.vector.riotx.core.utils.containsOnlyEmojis
    -import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
     import im.vector.riotx.features.html.PillImageSpan
     import kotlinx.coroutines.Dispatchers
    @@ -39,20 +39,25 @@ abstract class MessageTextItem : AbsMessageItem() {
         @EpoxyAttribute
         var message: CharSequence? = null
         @EpoxyAttribute
    -    override lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute
    -    override lateinit var informationData: MessageInformationData
    -    @EpoxyAttribute
         var urlClickCallback: TimelineEventController.UrlClickCallback? = null
     
    +    // Better link movement methods fixes the issue when
    +    // long pressing to open the context menu on a TextView also triggers an autoLink click.
         private val mvmtMethod = BetterLinkMovementMethod.newInstance().also {
             it.setOnLinkClickListener { _, url ->
                 //Return false to let android manage the click on the link, or true if the link is handled by the application
                 urlClickCallback?.onUrlClicked(url) == true
             }
    -        it.setOnLinkLongClickListener { _, url ->
    +        //We need also to fix the case when long click on link will trigger long click on cell
    +        it.setOnLinkLongClickListener { tv, url ->
                 //Long clicks are handled by parent, return true to block android to do something with url
    -            urlClickCallback?.onUrlLongClicked(url) == true
    +            if (urlClickCallback?.onUrlLongClicked(url) == true) {
    +                tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0))
    +                true
    +            } else {
    +                false
    +            }
    +
             }
         }
     
    @@ -73,7 +78,7 @@ abstract class MessageTextItem : AbsMessageItem() {
                     null)
     
             holder.messageView.setTextFuture(textFuture)
    -        holder.messageView.renderSendState()
    +        renderSendState(holder.messageView, holder.messageView)
             holder.messageView.setOnClickListener(cellClickListener)
             holder.messageView.setOnLongClickListener(longClickListener)
             findPillsAndProcess { it.bind(holder.messageView) }
    @@ -90,12 +95,13 @@ abstract class MessageTextItem : AbsMessageItem() {
             }
         }
     
    -    override fun getStubType(): Int = R.id.messageContentTextStub
    +    override fun getViewType() = STUB_ID
     
    -    class Holder : AbsMessageItem.Holder() {
    +    class Holder : AbsMessageItem.Holder(STUB_ID) {
             val messageView by bind(R.id.messageTextView)
    -        override fun getStubId(): Int = R.id.messageContentTextStub
    -
         }
     
    +    companion object {
    +        private const val STUB_ID = R.id.messageContentTextStub
    +    }
     }
    \ No newline at end of file
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt
    index 08645357..2879073f 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt
    @@ -57,10 +57,9 @@ abstract class NoticeItem : BaseEventItem() {
             holder.view.setOnLongClickListener(longClickListener)
         }
     
    -    override fun getStubType(): Int = STUB_ID
    +    override fun getViewType() = STUB_ID
     
    -    class Holder : BaseHolder() {
    -        override fun getStubId(): Int = STUB_ID
    +    class Holder : BaseHolder(STUB_ID) {
             val avatarImageView by bind(R.id.itemNoticeAvatarView)
             val noticeTextView by bind(R.id.itemNoticeTextView)
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RedactedMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RedactedMessageItem.kt
    index 05967f00..88e2be2b 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RedactedMessageItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RedactedMessageItem.kt
    @@ -16,28 +16,19 @@
     
     package im.vector.riotx.features.home.room.detail.timeline.item
     
    -import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import im.vector.riotx.R
    -import im.vector.riotx.features.home.AvatarRenderer
     
     @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
     abstract class RedactedMessageItem : AbsMessageItem() {
     
    -    @EpoxyAttribute
    -    override lateinit var informationData: MessageInformationData
    -    @EpoxyAttribute
    -    override lateinit var avatarRenderer: AvatarRenderer
    -
    -    override fun getStubType(): Int = STUB_ID
    +    override fun getViewType() = STUB_ID
     
         override fun shouldShowReactionAtBottom() = false
     
    -    class Holder : AbsMessageItem.Holder() {
    -        override fun getStubId(): Int = STUB_ID
    -    }
    +    class Holder : AbsMessageItem.Holder(STUB_ID)
     
         companion object {
    -        private val STUB_ID = R.id.messageContentRedactedStub
    +        private const val STUB_ID = R.id.messageContentRedactedStub
         }
    -}
    \ No newline at end of file
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/filtered/FilteredRoomFooterItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/filtered/FilteredRoomFooterItem.kt
    new file mode 100644
    index 00000000..777f0d32
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/filtered/FilteredRoomFooterItem.kt
    @@ -0,0 +1,52 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + * http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.home.room.filtered
    +
    +import android.widget.Button
    +import com.airbnb.epoxy.EpoxyAttribute
    +import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.riotx.R
    +import im.vector.riotx.core.epoxy.VectorEpoxyHolder
    +import im.vector.riotx.core.epoxy.VectorEpoxyModel
    +import im.vector.riotx.features.home.room.list.widget.FabMenuView
    +
    +@EpoxyModelClass(layout = R.layout.item_room_filter_footer)
    +abstract class FilteredRoomFooterItem : VectorEpoxyModel() {
    +
    +    @EpoxyAttribute
    +    var listener: FilteredRoomFooterItemListener? = null
    +
    +    @EpoxyAttribute
    +    var currentFilter: String = ""
    +
    +    override fun bind(holder: Holder) {
    +        holder.createRoomButton.setOnClickListener { listener?.createRoom(currentFilter) }
    +        holder.createDirectChat.setOnClickListener { listener?.createDirectChat() }
    +        holder.openRoomDirectory.setOnClickListener { listener?.openRoomDirectory(currentFilter) }
    +    }
    +
    +    class Holder : VectorEpoxyHolder() {
    +        val createRoomButton by bind