diff --git a/CHANGES.md b/CHANGES.md index 7b52a03795..dfda3cef02 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 2fe92f5808..d9f94454cd 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 91415088e5..b52707d527 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 290a63a652..328eb86954 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 3eaf43eb69..b62b3fea9d 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 cfe5a051e7..fb3dbcc26c 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 08e706c3e4..2dde175bed 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 56d4801c45..0f5421a05a 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 c45e47fcc4..bd32a75a47 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 9bbdf5ab97..5f89a482d0 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 81d7ddd4c0..0c4e1bebc1 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 f626e3a79b..044aa957f3 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 a2af70f401..cc1c3f1a32 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 0000000000..ad17d26b20 --- /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 7cc73ceb37..21f16d3d03 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 d52a457c9d..3fadb09bc0 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 180b7aa669..b30176b2c4 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 108ce056eb..4d06b737e8 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 33e33ee0ae..54a4e14dc8 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 706152931c..3bda568d3a 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 01d95eb289..948af2af96 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 c811ece154..a1e58c9029 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 d6d2d9cc6c..cbd4d0c674 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 bbd5859b21..4dfc5810e2 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 0000000000..7528dee201 --- /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 6a1cda534a..09baebb2c7 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 d1673bfef3..f2e61e8c2a 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 b55a7e14c9..867ca2874f 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 af26397046..361a935d2f 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 3ed2fa4348..9161fb25a1 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 143ef60b46..98cf872b10 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 09322e6a1a..942239ea12 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 8fdb5fe9d0..6bcac9b8f3 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 d98436a704..a30906053d 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 01ae43943d..948f1741db 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 5d64a63272..0000000000 --- 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 72b2695ebc..fb8326f287 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 8ea8c3370d..24dc14a72f 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 3eb1c066a8..183f2a6c2c 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 0000000000..7afbe2884b --- /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 9805607c4c..737165ff0f 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 67d1eabc66..c6b6864704 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 3061bd834b..bf7cb36188 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 87f78224cd..e1a8bdd746 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 ae60969bf1..fb8b62716f 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 bdf8106461..215321bd42 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 3d84d63ec0..598d5f0717 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 b6d236edbf..c08f6101b2 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 eb331e415c..0000000000 --- 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 0000000000..188c7d84bf --- /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 5c722863c9..0000000000 --- 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 a35f5a3b6c..00368dfa9d 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 00bcf69ecd..a211ea04af 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 ede965c176..faf1840a8d 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 be64a65974..5459cf9124 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 d03dfa79b9..0d2c4cc409 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 b4f27d7eb0..6a7a8e3a22 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 0000000000..185f7afb66 --- /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 6c9f16c67e..5a4aa36c74 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 7e5242ab2e..e0deced966 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 cec7c7183d..9e87d466af 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 06f6512939..a42eec4940 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 234d4a0caf..534a346a1c 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 7581777924..b4afb569c4 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 b03686d488..95e17a4b7d 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 c82631c0b2..db171300e6 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 7a79bf377a..1570a7f82e 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 e5e8451450..b0747e7e86 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 ebaeb2d39e..f0a62ccd5a 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 0000000000..3c9ce20de3
    --- /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 0000000000..56daca223e
    --- /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 f387b29619..9c7b793825 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 c4da3038e0..1d7a6a3515 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 3a4a952892..45995434ea 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 67d1af4e44..4ec2c0ad95 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 db8ae35950..bd4b2ca4df 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 832e8a5e4f..ac4cc08dfc 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 4e0b5b70ed..df8cd411bb 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 229652b0a5..513379bdcf 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 d52b16ca04..ace0802e09 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 9108f5d08f..6ad9a61f1a 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 f611fe9973..b7491ae6b2 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 2a19914a8c..d38561fa88 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 0000000000..cb283511ad
    --- /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 488ecbd334..4a3fc3ffad 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 b6cf00a3c6..0b78f8153f 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 21f5da52fa..5b0dbdfed2 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 0000000000..aefbde431a
    --- /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 0000000000..fc11f25561
    --- /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 0000000000..6ad172101a
    --- /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 760b74daf6..d7e41784ea 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 57c3d26528..74b3f4925f 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 2a4a0c0f67..080565cd16 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 d8f1c602d5..3a0d2d1dc5 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 688cac3db9..ca79666747 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 7a66c6adcf..88697db4dc 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 3a7d09e20d..843f52b34c 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 cc50493aea..0b30facfae 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 0ad13fcfb6..4f26f9bb11 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 66b368dfd8..45e57b59db 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 67f0ed7bda..d551e44c23 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 3ab428e014..fc867b1277 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 0864535761..2879073f18 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 05967f0086..88e2be2bc5 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 0000000000..777f0d3266
    --- /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