Merge branch 'develop' into feature/create_direct_room

This commit is contained in:
ganfra 2019-07-18 09:47:25 +02:00
commit 4341b0d0f5
95 changed files with 1106 additions and 694 deletions

View File

@ -2,19 +2,25 @@ Changes in RiotX 0.2.1 (2019-XX-XX)
===================================================

Features:
- Message Editing: View edit history
- Message Editing: View edit history (#121)
- Rooms filtering (#304)

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:
-

View File

@ -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 {
@ -52,6 +54,19 @@ allprojects {
}
}
}

tasks.withType(JavaCompile).all {
options.compilerArgs += [
'-Adagger.gradle.incremental=enabled'
]
}

afterEvaluate {
extensions.findByName("kapt")?.arguments {
arg("dagger.gradle.incremental", "enabled")
}
}

}

task clean(type: Delete) {

View File

@ -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'

View File

@ -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.

View File

@ -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"

}

View File

@ -25,4 +25,9 @@ interface MessageContent {
val body: String
val relatesTo: RelationDefaultContent?
val newContent: Content?
}


fun MessageContent?.isReply(): Boolean {
return this?.relatesTo?.inReplyTo != null
}

View File

@ -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?

View File

@ -80,6 +80,22 @@ interface RelationService {
newBodyAutoMarkdown: Boolean,
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 originalSenderId the sender of the message that this reply (being edited) is relating to
* @param originalEventId the event id 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,
originalSenderId: String?,
originalEventId : String,
newBodyText: String,
compatibilityBodyText: String = "* $newBodyText"): Cancelable

/**
* Get's the edit history of the given event
*/

View File

@ -21,7 +21,9 @@ 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

/**
* This data class is a wrapper around an Event. It allows to get useful data in the context of a timeline.
@ -88,3 +90,15 @@ data class TimelineEvent(
*/
fun TimelineEvent.getLastMessageContent(): MessageContent? = annotations?.editSummary?.aggregatedContent?.toModel()
?: root.getClearContent().toModel()


fun TimelineEvent.getTextEditableContent(): String? {
val originalContent = root.getClearContent().toModel<MessageContent>() ?: return null
val isReply = originalContent.isReply()
val lastContent = getLastMessageContent()
return if (isReply) {
return extractUsefulTextFromReply(lastContent?.body ?: "")
} else {
lastContent?.body ?: ""
}
}

View File

@ -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<String>()
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("<mx-reply>")) {
val closingTagIndex = repliedBody.lastIndexOf("</mx-reply>")
if (closingTagIndex != -1)
return repliedBody.substring(closingTagIndex + "</mx-reply>".length).trim()
}
return repliedBody
}
}

View File

@ -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
}
}

}

View File

@ -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<MXEncryptEventContentResult>) {
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<MXEventDecryptionResult>) {
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<RoomKeyContent>() ?: 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<ByteArray>) {
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<ByteArray>) {
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<MegolmSessionData>()
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<String>, callback: MatrixCallback<Unit>) {
// 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<MXDeviceInfo>()
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<String>, forceDownload: Boolean, callback: MatrixCallback<MXUsersDevicesMap<MXDeviceInfo>>) {
CoroutineScope(coroutineDispatchers.crypto).launch {
GlobalScope.launch(coroutineDispatchers.crypto) {
deviceListManager
.downloadKeys(userIds, forceDownload)
.foldToCallback(callback)

View File

@ -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<String, List<MXDeviceInfo>>()
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<String, Any>()
payloadJson["type"] = EventType.FORWARDED_ROOM_KEY
val payloadJson = mutableMapOf<String, Any>("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<Any>()
sendToDeviceMap.setObject(userId, deviceId, encodedPayload)
Timber.v("## shareKeysWithDevice() : sending to $userId:$deviceId")

View File

@ -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)

View File

@ -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<String>) {
}
}

@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

View File

@ -37,25 +37,22 @@ internal fun RoomEntity.addOrUpdate(chunkEntity: ChunkEntity) {
}
}

internal fun RoomEntity.addStateEvents(stateEvents: List<Event>,
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 ?: ""

View File

@ -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,

View File

@ -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 <DATA> executeRequest(block: Request<DATA>.() -> Unit) = Request<DATA>().apply(block).execute()

@ -43,30 +37,22 @@ internal class Request<DATA> {
lateinit var apiCall: Call<DATA>

suspend fun execute(): Try<DATA> {
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 {

View File

@ -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 <T> Call<T>.awaitResponse(): Response<T> {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
continuation.resume(response)
}

override fun onFailure(call: Call<T>, t: Throwable) {
continuation.resumeWithException(t)
}
})
}
}

View File

@ -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 {

View File

@ -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


View File

@ -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<RoomAvatarContent>()?.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<RoomMember>()
}
}

View File

@ -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<RoomTopicContent>()?.topic
roomSummaryEntity.latestEvent = latestEvent
roomSummaryEntity.otherMemberIds.clear()
roomSummaryEntity.otherMemberIds.addAll(otherRoomMembers.keys)
roomSummaryEntity.otherMemberIds.addAll(otherRoomMembers)

}
}

View File

@ -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<LoadRoomMembersTask.Params, Boolean> {
@ -60,23 +64,26 @@ internal class DefaultLoadRoomMembersTask @Inject constructor(private val roomAP
}
}

private fun insertInDb(response: RoomMembersResponse, roomId: String): Try<RoomMembersResponse> {
private fun insertInDb(response: RoomMembersResponse, roomId: String): Try<Unit> {
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
}
}

}
}

View File

@ -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<String> = 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<RoomMember>()
}
}

View File

@ -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, RoomMember>): 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
}

}

View File

@ -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<EventEntity> {
return queryRoomMembersEvent().contains(EventEntityFields.CONTENT, "\"membership\":\"join\"")
}

fun queryInvitedRoomMembersEvent(): RealmQuery<EventEntity> {
return queryRoomMembersEvent().contains(EventEntityFields.CONTENT, "\"membership\":\"invite\"")
}

fun queryRoomMemberEvent(userId: String): RealmQuery<EventEntity> {
return queryRoomMembersEvent()
.equalTo(EventEntityFields.STATE_KEY, userId)
}

fun getLoaded(): Map<String, RoomMember> {
return queryRoomMembersEvent()
.findAll()
.map { it.asDomain() }
.associateBy { it.stateKey!! }
.mapValues { it.value.content.toModel<RoomMember>()!! }
}

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()
}

}
}

View File

@ -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
@ -132,6 +133,24 @@ internal class DefaultRelationService @Inject constructor(private val context: C

}

override fun editReply(replyToEdit: TimelineEvent,
originalSenderId: String?,
originalEventId: String,
newBodyText: String,
compatibilityBodyText: String): Cancelable {
val event = eventFactory
.createReplaceTextOfReply(roomId,
replyToEdit,
originalSenderId, originalEventId,
newBodyText, true, MessageType.MSGTYPE_TEXT, compatibilityBodyText)
.also {
saveLocalEcho(it)
}
val workRequest = createSendEventWork(event)
TimelineSendEventWorkCommon.postWork(context, roomId, workRequest)
return CancelableWork(context, workRequest.id)
}

override fun fetchEditHistory(eventId: String, callback: MatrixCallback<List<Event>>) {
val params = FetchEditHistoryTask.Params(roomId, eventId)
fetchEditHistoryTask.configureWith(params)

View File

@ -104,6 +104,45 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
))
}

fun createReplaceTextOfReply(roomId: String, eventReplaced: TimelineEvent,
originalSenderId: String?,
originalEventId: String,
newBodyText: String,
newBodyAutoMarkdown: Boolean,
msgType: String,
compatibilityText: String): Event {
val permalink = PermalinkFactory.createPermalink(roomId, originalEventId)
val userLink = originalSenderId?.let { PermalinkFactory.createPermalink(it) } ?: ""

val body = bodyForReply(eventReplaced.getLastMessageContent(), eventReplaced.root.getClearContent().toModel())
val replyFormatted = REPLY_PATTERN.format(
permalink,
stringProvider.getString(R.string.message_reply_to_prefix),
userLink,
originalSenderId,
body.takeFormatted(),
createTextContent(newBodyText, newBodyAutoMarkdown).takeFormatted()
)
//
// > <@alice:example.org> This is the original body
//
val replyFallback = buildReplyFallback(body, originalSenderId, 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
// <mx-reply>
// <blockquote>
// <a href="https://matrix.to/#/!somewhere:domain.com/$event:domain.com">In reply to</a>
// <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
// <br />
// <!-- This is where the related event's HTML would be. -->
// </blockquote>
// </mx-reply>
// 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

// <mx-reply>
// <blockquote>
// <a href="https://matrix.to/#/!somewhere:domain.com/$event:domain.com">In reply to</a>
// <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
// <br />
// <!-- This is where the related event's HTML would be. -->
// </blockquote>
// </mx-reply>
// No whitespace because currently breaks temporary formatted text to Span
const val REPLY_PATTERN = """<mx-reply><blockquote><a href="%s">%s</a><a href="%s">%s</a><br />%s</blockquote></mx-reply>%s"""

fun isLocalEchoId(eventId: String): Boolean = eventId.startsWith(LOCAL_ID_PREFIX)

View File

@ -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<String>()
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("<mx-reply>")) {
return repliedBody.substring(repliedBody.lastIndexOf("</mx-reply>") + "</mx-reply>".length).trim()
}
return repliedBody
}

View File

@ -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


View File

@ -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<String>(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 {

View File

@ -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<Event>,
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<String>(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) {

View File

@ -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

View File

@ -54,6 +54,10 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask,
updateStateTo(SyncState.IDLE)
}

fun setInitialForeground(initialForground : Boolean) {
updateStateTo(if (initialForground) SyncState.IDLE else SyncState.PAUSED)
}

fun restart() = synchronized(lock) {
if (state is SyncState.PAUSED) {
Timber.v("Resume sync...")
@ -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,8 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask,
lock.wait()
}
} else {
Timber.v("Execute sync request with timeout $DEFAULT_LONG_POOL_TIMEOUT")
updateStateTo(SyncState.RUNNING(catchingUp = 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)
@ -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)
}

View File

@ -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<UpdateUserTask.Params, Unit> {

data class Params(val eventIds: List<String>)

}

internal class DefaultUpdateUserTask @Inject constructor(private val monarchy: Monarchy) : UpdateUserTask {

override suspend fun execute(params: UpdateUserTask.Params): Try<Unit> {
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 ?: ""
}
}
}

}

View File

@ -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<RoomMember>() ?: return null
return UserEntity(event.stateKey ?: "",
roomMember.displayName ?: "",
roomMember.avatarUrl ?: ""
)
}


}

View File

@ -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<EventEntity>(realmConfiguration) {

override val query = Monarchy.Query<EventEntity> {
EventEntity
.types(it, listOf(EventType.STATE_ROOM_MEMBER))
.sort(EventEntityFields.STATE_INDEX, Sort.DESCENDING)
.distinct(EventEntityFields.STATE_KEY)
}

override fun onChange(results: RealmResults<EventEntity>, 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)
}

}

View File

@ -26,7 +26,4 @@ internal abstract class UserModule {
@Binds
abstract fun bindUserService(userService: DefaultUserService): UserService

@Binds
abstract fun bindUpdateUserTask(updateUserTask: DefaultUpdateUserTask): UpdateUserTask

}

View File

@ -187,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"

View File

@ -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()
}

View File

@ -59,9 +59,15 @@ import im.vector.riotx.features.workers.signout.SignOutViewModel
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)
@ -112,6 +118,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


View File

@ -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()

View File

@ -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<S : MvRxState>(initialState: S)
: BaseMvRxViewModel<S>(initialState, false)
: BaseMvRxViewModel<S>(initialState, false) {

protected val cancelableBag = CancelableBag()

override fun onCleared() {
super.onCleared()
cancelableBag.cancel()
}


}

View File

@ -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)
}

}

View File

@ -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)
}
}


View File

@ -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) {

View File

@ -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) {

View File

@ -51,8 +51,7 @@ class HomeDrawerFragment : VectorBaseFragment() {
val groupListFragment = GroupListFragment.newInstance()
replaceChildFragment(groupListFragment, R.id.homeDrawerGroupListContainer)
}

session.liveUser(session.sessionParams.credentials.userId).observeK(this) { user ->
session.liveUser(session.myUserId).observeK(this) { user ->
if (user != null) {
avatarRenderer.render(user.avatarUrl, user.userId, user.displayName, homeDrawerHeaderAvatarView)
homeDrawerUsernameView.text = user.displayName

View File

@ -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),

View File

@ -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
@ -87,7 +91,7 @@ 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.*
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
@ -258,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
@ -323,6 +327,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() {
@ -486,7 +516,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)

@ -572,7 +602,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
}

@ -780,7 +810,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)) {
@ -828,10 +858,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)
}
}

View File

@ -39,7 +39,6 @@ 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.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 +51,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 +94,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
observeRoomSummary()
observeEventDisplayedActions()
observeInvitationState()
room.loadRoomMembersIfNeeded()
cancelableBag += room.loadRoomMembersIfNeeded()
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
}
@ -229,16 +226,24 @@ 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<MessageContent>()?.relatesTo?.inReplyTo?.eventId
if (inReplyTo != null) {
//TODO check if same content?
room.editReply(state.sendMode.timelineEvent, room.getTimeLineEvent(inReplyTo)?.root?.senderId, inReplyTo, 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(
@ -347,7 +352,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)
}


@ -355,7 +360,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)
}
}


View File

@ -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
)
}

}

View File

@ -127,26 +127,28 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
//TODO is downloading attachement?

if (!event.root.isRedacted()) {
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 (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, eventId))
}

if (canEdit(event, session.sessionParams.credentials.userId)) {
if (canEdit(event, session.myUserId)) {
this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, eventId))
}

if (canRedact(event, session.sessionParams.credentials.userId)) {
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))
}
@ -183,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))
}

View File

@ -26,6 +26,7 @@ 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
@ -60,13 +61,13 @@ class ViewEditHistoryEpoxyController(private val context: Context,
}
}
is Success -> {
state.editList()?.let { renderEvents(it) }
state.editList()?.let { renderEvents(it, state.isOriginalAReply) }
}

}
}

private fun renderEvents(sourceEvents: List<Event>) {
private fun renderEvents(sourceEvents: List<Event>, isOriginalReply: Boolean) {
if (sourceEvents.isEmpty()) {
genericItem {
id("footer")
@ -92,7 +93,7 @@ class ViewEditHistoryEpoxyController(private val context: Context,
}
}
lastDate = evDate
val cContent = getCorrectContent(timelineEvent)
val cContent = getCorrectContent(timelineEvent, isOriginalReply)
val body = cContent.second?.let { eventHtmlRenderer.render(it) }
?: cContent.first

@ -101,7 +102,7 @@ class ViewEditHistoryEpoxyController(private val context: Context,
var spannedDiff: Spannable? = null
if (nextEvent != null && cContent.second == null /*No diff for html*/) {
//compares the body
val nContent = getCorrectContent(nextEvent)
val nContent = getCorrectContent(nextEvent, isOriginalReply)
val nextBody = nContent.second?.let { eventHtmlRenderer.render(it) }
?: nContent.first
val dmp = diff_match_patch()
@ -144,11 +145,14 @@ class ViewEditHistoryEpoxyController(private val context: Context,
}
}

private fun getCorrectContent(event: Event): Pair<String, String?> {
private fun getCorrectContent(event: Event, isOriginalReply: Boolean): Pair<String, String?> {
val clearContent = event.getClearContent().toModel<MessageTextContent>()
val newContent = clearContent
?.newContent
?.toModel<MessageTextContent>()
if (isOriginalReply) {
return extractUsefulTextFromReply(newContent?.body ?: clearContent?.body ?: "") to null
}
return (newContent?.body ?: clearContent?.body ?: "") to (newContent?.formattedBody
?: clearContent?.formattedBody)
}

View File

@ -21,6 +21,9 @@ 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.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.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter

@ -28,6 +31,7 @@ import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFor
data class ViewEditHistoryViewState(
val eventId: String,
val roomId: String,
val isOriginalAReply: Boolean = false,
val editList: Async<List<Event>> = Uninitialized)
: MvRxState {

@ -77,11 +81,16 @@ class ViewEditHistoryViewModel @AssistedInject constructor(@Assisted
override fun onSuccess(data: List<Event>) {
//TODO until supported by API Add original event manually
val withOriginal = data.toMutableList()
var originalIsReply = false
room.getTimeLineEvent(eventId)?.let {
withOriginal.add(it.root)
originalIsReply = it.root.getClearContent().toModel<MessageContent>().isReply()
}
setState {
copy(editList = Success(withOriginal))
copy(
editList = Success(withOriginal),
isOriginalAReply = originalIsReply
)
}
}
})

View File

@ -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
@ -40,14 +41,23 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@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
}

}
}


View File

@ -30,10 +30,13 @@ abstract class FilteredRoomFooterItem : VectorEpoxyModel<FilteredRoomFooterItem.
@EpoxyAttribute
var listener: FilteredRoomFooterItemListener? = null

@EpoxyAttribute
var currentFilter: String = ""

override fun bind(holder: Holder) {
holder.createRoomButton.setOnClickListener { listener?.createRoom() }
holder.createRoomButton.setOnClickListener { listener?.createRoom(currentFilter) }
holder.createDirectChat.setOnClickListener { listener?.createDirectChat() }
holder.openRoomDirectory.setOnClickListener { listener?.openRoomDirectory() }
holder.openRoomDirectory.setOnClickListener { listener?.openRoomDirectory(currentFilter) }
}

class Holder : VectorEpoxyHolder() {
@ -43,7 +46,7 @@ abstract class FilteredRoomFooterItem : VectorEpoxyModel<FilteredRoomFooterItem.
}

interface FilteredRoomFooterItemListener : FabMenuView.Listener {
fun createRoom()
fun createRoom(initialName: String)
}
}


View File

@ -20,17 +20,15 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.replaceFragment
import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.home.room.list.RoomListParams
import kotlinx.android.synthetic.main.activity_filtered_rooms.*

class FilteredRoomsActivity : VectorBaseActivity(), ToolbarConfigurable {
class FilteredRoomsActivity : VectorBaseActivity() {

private lateinit var roomListFragment: RoomListFragment

@ -44,6 +42,9 @@ class FilteredRoomsActivity : VectorBaseActivity(), ToolbarConfigurable {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

configureToolbar(filteredRoomsToolbar)

if (isFirstCreation()) {
roomListFragment = RoomListFragment.newInstance(RoomListParams(RoomListFragment.DisplayMode.FILTERED))
replaceFragment(roomListFragment, R.id.filteredRoomsFragmentContainer, FRAGMENT_TAG)
@ -67,10 +68,6 @@ class FilteredRoomsActivity : VectorBaseActivity(), ToolbarConfigurable {
filteredRoomsSearchView.requestFocus()
}

override fun configure(toolbar: Toolbar) {
configureToolbar(toolbar)
}

companion object {
private const val FRAGMENT_TAG = "RoomListFragment"


View File

@ -38,6 +38,7 @@ import im.vector.riotx.core.platform.OnBackPressed
import im.vector.riotx.core.platform.StateView
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.features.home.room.list.widget.FabMenuView
import im.vector.riotx.features.notifications.NotificationDrawerManager
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_list.*
import javax.inject.Inject
@ -69,6 +70,7 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Listener, O
@Inject lateinit var roomController: RoomSummaryController
@Inject lateinit var roomListViewModelFactory: RoomListViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
private val roomListViewModel: RoomListViewModel by fragmentViewModel()

override fun getLayoutResId() = R.layout.fragment_room_list
@ -112,7 +114,7 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Listener, O
}

// Hide FAB when list is scrolling
epoxyRecyclerView.addOnScrollListener(
roomListEpoxyRecyclerView.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
createChatFabMenu.removeCallbacks(showFabRunnable)
@ -136,11 +138,14 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Listener, O
}

fun filterRoomsWith(filter: String) {
// Scroll the list to top
roomListEpoxyRecyclerView.scrollToPosition(0)

roomListViewModel.accept(RoomListActions.FilterWith(filter))
}

override fun openRoomDirectory() {
navigator.openRoomDirectory(requireActivity())
override fun openRoomDirectory(initialFilter: String) {
navigator.openRoomDirectory(requireActivity(), initialFilter)
}

override fun createDirectChat() {
@ -150,12 +155,12 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Listener, O
private fun setupRecyclerView() {
val layoutManager = LinearLayoutManager(context)
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
epoxyRecyclerView.layoutManager = layoutManager
epoxyRecyclerView.itemAnimator = RoomListAnimator()
roomListEpoxyRecyclerView.layoutManager = layoutManager
roomListEpoxyRecyclerView.itemAnimator = RoomListAnimator()
roomController.listener = this
roomController.addModelBuildListener { it.dispatchTo(stateRestorer) }
stateView.contentView = epoxyRecyclerView
epoxyRecyclerView.setController(roomController)
stateView.contentView = roomListEpoxyRecyclerView
roomListEpoxyRecyclerView.setController(roomController)
}

private val showFabRunnable = Runnable {
@ -255,10 +260,12 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Listener, O
}

override fun onAcceptRoomInvitation(room: RoomSummary) {
notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId)
roomListViewModel.accept(RoomListActions.AcceptInvitation(room))
}

override fun onRejectRoomInvitation(room: RoomSummary) {
notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId)
roomListViewModel.accept(RoomListActions.RejectInvitation(room))
}

@ -266,9 +273,8 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Listener, O
roomListViewModel.accept(RoomListActions.ToggleCategory(roomCategory))
}

// TODO Pass title
override fun createRoom() {
navigator.openCreateRoom(requireActivity())
override fun createRoom(initialName: String) {
navigator.openCreateRoom(requireActivity(), initialName)
}

}

View File

@ -70,13 +70,14 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri
viewState.rejectingRoomsIds,
viewState.rejectingErrorRoomsIds)

addFilterFooter()
addFilterFooter(viewState)
}

private fun addFilterFooter() {
private fun addFilterFooter(viewState: RoomListViewState) {
filteredRoomFooterItem {
id("filter_footer")
listener(listener)
currentFilter(viewState.roomFilter)
}
}


View File

@ -92,7 +92,7 @@ class FabMenuView @JvmOverloads constructor(context: Context, attrs: AttributeSe

interface Listener {
fun createDirectChat()
fun openRoomDirectory()
fun openRoomDirectory(initialFilter: String = "")
}

}

View File

@ -22,7 +22,7 @@ import android.os.Bundle
import android.widget.Toast
import androidx.core.view.isVisible
import arrow.core.Try
import com.jakewharton.rxbinding2.widget.RxTextView
import com.jakewharton.rxbinding3.widget.textChanges
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.auth.Authenticator
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
@ -127,9 +127,9 @@ class LoginActivity : VectorBaseActivity() {
private fun setupAuthButton() {
Observable
.combineLatest(
RxTextView.textChanges(loginField).map { it.trim().isNotEmpty() },
RxTextView.textChanges(passwordField).map { it.trim().isNotEmpty() },
RxTextView.textChanges(homeServerField).map { it.trim().isNotEmpty() },
loginField.textChanges().map { it.trim().isNotEmpty() },
passwordField.textChanges().map { it.trim().isNotEmpty() },
homeServerField.textChanges().map { it.trim().isNotEmpty() },
Function3<Boolean, Boolean, Boolean, Boolean> { isLoginNotEmpty, isPasswordNotEmpty, isHomeServerNotEmpty ->
isLoginNotEmpty && isPasswordNotEmpty && isHomeServerNotEmpty
}

View File

@ -60,13 +60,13 @@ class DefaultNavigator @Inject constructor() : Navigator {
context.startActivity(intent)
}

override fun openRoomDirectory(context: Context) {
val intent = Intent(context, RoomDirectoryActivity::class.java)
override fun openRoomDirectory(context: Context, initialFilter: String) {
val intent = RoomDirectoryActivity.getIntent(context, initialFilter)
context.startActivity(intent)
}

override fun openCreateRoom(context: Context) {
val intent = CreateRoomActivity.getIntent(context)
override fun openCreateRoom(context: Context, initialName: String) {
val intent = CreateRoomActivity.getIntent(context, initialName)
context.startActivity(intent)
}


View File

@ -27,11 +27,11 @@ interface Navigator {

fun openRoomPreview(publicRoom: PublicRoom, context: Context)

fun openCreateRoom(context: Context)
fun openCreateRoom(context: Context, initialName: String = "")

fun openCreateDirectRoom(context: Context)

fun openRoomDirectory(context: Context)
fun openRoomDirectory(context: Context, initialFilter: String = "")

fun openRoomsFiltering(context: Context)


View File

@ -70,7 +70,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
val bodyPreview = event.type

return SimpleNotifiableEvent(
session.sessionParams.credentials.userId,
session.myUserId,
eventId = event.eventId!!,
noisy = false,//will be updated
timestamp = event.originServerTs ?: System.currentTimeMillis(),
@ -109,7 +109,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
roomId = event.root.roomId!!,
roomName = roomName)

notifiableEvent.matrixID = session.sessionParams.credentials.userId
notifiableEvent.matrixID = session.myUserId
return notifiableEvent
} else {
if (event.root.isEncrypted() && event.root.mxDecryptionResult == null) {
@ -145,7 +145,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
roomName = roomName,
roomIsDirect = room.roomSummary()?.isDirect ?: false)

notifiableEvent.matrixID = session.sessionParams.credentials.userId
notifiableEvent.matrixID = session.myUserId
notifiableEvent.soundName = null

// Get the avatars URL
@ -175,7 +175,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
val body = noticeEventFormatter.format(event, dName)
?: stringProvider.getString(R.string.notification_new_invitation)
return InviteNotifiableEvent(
session.sessionParams.credentials.userId,
session.myUserId,
eventId = event.eventId!!,
roomId = roomId,
timestamp = event.originServerTs ?: 0,

View File

@ -121,9 +121,9 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
UUID.randomUUID().toString(),
false,
System.currentTimeMillis(),
session.getUser(session.sessionParams.credentials.userId)?.displayName
session.getUser(session.myUserId)?.displayName
?: context?.getString(R.string.notification_sender_me),
session.sessionParams.credentials.userId,
session.myUserId,
message,
room.roomId,
room.roomSummary()?.displayName ?: room.roomId,

View File

@ -181,8 +181,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context

val session = activeSessionHolder.getSafeActiveSession() ?: return

val user = session.getUser(session.sessionParams.credentials.userId)
val myUserDisplayName = user?.displayName ?: session.sessionParams.credentials.userId
val user = session.getUser(session.myUserId)
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = user?.displayName?.takeIf { it.isNotBlank() } ?: session.myUserId
val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(user?.avatarUrl, avatarSize, avatarSize, ContentUrlResolver.ThumbnailMethod.SCALE)
synchronized(eventList) {

@ -343,7 +344,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
for (event in simpleEvents) {
//We build a simple event
if (firstTime || !event.hasBeenDisplayed) {
NotificationUtils.buildSimpleEventNotification(context, event, null, myUserDisplayName)?.let {
NotificationUtils.buildSimpleEventNotification(context, event, null, session.myUserId)?.let {
notifications.add(it)
NotificationUtils.showNotificationMessage(context, event.eventId, ROOM_EVENT_NOTIFICATION_ID, it)
event.hasBeenDisplayed = true //we can consider it as displayed

View File

@ -204,7 +204,7 @@ class BugReporter @Inject constructor(private val activeSessionHolder: ActiveSes
var olmVersion = "undefined"

activeSessionHolder.getSafeActiveSession()?.let { session ->
userId = session.sessionParams.credentials.userId
userId = session.myUserId
deviceId = session.sessionParams.credentials.deviceId ?: "undefined"
olmVersion = session.getCryptoVersion(context, true)
}

View File

@ -25,14 +25,13 @@ import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding2.widget.RxTextView
import com.jakewharton.rxbinding3.appcompat.queryTextChanges
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.features.themes.ThemeUtils
import io.reactivex.rxkotlin.subscribeBy
import kotlinx.android.synthetic.main.fragment_public_rooms.*
import timber.log.Timber
@ -70,9 +69,7 @@ class PublicRoomsFragment : VectorBaseFragment(), PublicRoomsController.Callback
it.setDisplayHomeAsUpEnabled(true)
}

publicRoomsFilter.setBackgroundResource(ThemeUtils.getResourceId(requireContext(), R.drawable.bg_search_edit_text_light))

RxTextView.textChanges(publicRoomsFilter)
publicRoomsFilter.queryTextChanges()
.debounce(500, TimeUnit.MILLISECONDS)
.subscribeBy {
viewModel.filterWith(it.toString())
@ -147,6 +144,11 @@ class PublicRoomsFragment : VectorBaseFragment(), PublicRoomsController.Callback
}

override fun invalidate() = withState(viewModel) { state ->
if (publicRoomsFilter.query.toString() != state.currentFilter) {
// For initial filter
publicRoomsFilter.setQuery(state.currentFilter, false)
}

// Populate list with Epoxy
publicRoomsController.setData(state)
}

View File

@ -22,6 +22,8 @@ import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom

data class PublicRoomsViewState(
// The current filter
val currentFilter: String = "",
// Store cumul of pagination result
val publicRooms: List<PublicRoom> = emptyList(),
// Current pagination request

View File

@ -16,8 +16,11 @@

package im.vector.riotx.features.roomdirectory

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.ViewModelProviders
import com.airbnb.mvrx.viewModel
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.addFragment
@ -25,6 +28,7 @@ import im.vector.riotx.core.extensions.addFragmentToBackstack
import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.roomdirectory.createroom.CreateRoomFragment
import im.vector.riotx.features.roomdirectory.createroom.CreateRoomViewModel
import im.vector.riotx.features.roomdirectory.picker.RoomDirectoryPickerFragment
import javax.inject.Inject

@ -39,7 +43,10 @@ class RoomDirectoryActivity : VectorBaseActivity() {
}


@Inject lateinit var createRoomViewModelFactory: CreateRoomViewModel.Factory
@Inject lateinit var roomDirectoryViewModelFactory: RoomDirectoryViewModel.Factory
private val roomDirectoryViewModel: RoomDirectoryViewModel by viewModel()
private val createRoomViewModel: CreateRoomViewModel by viewModel()
private lateinit var navigationViewModel: RoomDirectoryNavigationViewModel

override fun getLayoutRes() = R.layout.activity_simple
@ -51,6 +58,11 @@ class RoomDirectoryActivity : VectorBaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
navigationViewModel = ViewModelProviders.of(this, viewModelFactory).get(RoomDirectoryNavigationViewModel::class.java)

if (isFirstCreation()) {
roomDirectoryViewModel.filterWith(intent?.getStringExtra(INITIAL_FILTER) ?: "")
}

navigationViewModel.navigateTo.observeEvent(this) { navigation ->
when (navigation) {
is Navigation.Back -> onBackPressed()
@ -59,6 +71,11 @@ class RoomDirectoryActivity : VectorBaseActivity() {
is Navigation.Close -> finish()
}
}

roomDirectoryViewModel.selectSubscribe(this, PublicRoomsViewState::currentFilter) { currentFilter ->
// Transmit the filter to the createRoomViewModel
createRoomViewModel.setName(currentFilter)
}
}

override fun initUiAndData() {
@ -67,4 +84,13 @@ class RoomDirectoryActivity : VectorBaseActivity() {
}
}

companion object {
private const val INITIAL_FILTER = "INITIAL_FILTER"

fun getIntent(context: Context, initialFilter: String = ""): Intent {
val intent = Intent(context, RoomDirectoryActivity::class.java)
intent.putExtra(INITIAL_FILTER, initialFilter)
return intent
}
}
}

View File

@ -59,9 +59,6 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
get() = _joinRoomErrorLiveData


// TODO Store in ViewState?
private var currentFilter: String = ""

private var since: String? = null

private var currentTask: Cancelable? = null
@ -70,9 +67,6 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
private var roomDirectoryData = RoomDirectoryData()

init {
// Load with empty filter
load()

setState {
copy(
roomDirectoryDisplayName = roomDirectoryData.displayName
@ -115,24 +109,20 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:

this.roomDirectoryData = roomDirectoryData

reset()
load()
reset("")
load("")
}

fun filterWith(filter: String) {
if (currentFilter == filter) {
return
fun filterWith(filter: String) = withState { state ->
if (state.currentFilter != filter) {
currentTask?.cancel()

reset(filter)
load(filter)
}

currentTask?.cancel()

currentFilter = filter

reset()
load()
}

private fun reset() {
private fun reset(newFilter: String) {
// Reset since token
since = null

@ -141,12 +131,13 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
publicRooms = emptyList(),
asyncPublicRoomsRequest = Loading(),
hasMore = false,
roomDirectoryDisplayName = roomDirectoryData.displayName
roomDirectoryDisplayName = roomDirectoryData.displayName,
currentFilter = newFilter
)
}
}

fun loadMore() {
fun loadMore() = withState { state ->
if (currentTask == null) {
setState {
copy(
@ -154,15 +145,15 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState:
)
}

load()
load(state.currentFilter)
}
}

private fun load() {
private fun load(filter: String) {
currentTask = session.getPublicRooms(roomDirectoryData.homeServer,
PublicRoomsParams(
limit = PUBLIC_ROOMS_LIMIT,
filter = PublicRoomsFilter(searchTerm = currentFilter),
filter = PublicRoomsFilter(searchTerm = filter),
includeAllNetworks = roomDirectoryData.includeAllNetworks,
since = since,
thirdPartyInstanceId = roomDirectoryData.thirdPartyInstanceId

View File

@ -21,6 +21,7 @@ import android.content.Intent
import android.os.Bundle
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.ViewModelProviders
import com.airbnb.mvrx.viewModel
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.addFragment
@ -29,12 +30,16 @@ import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity
import im.vector.riotx.features.roomdirectory.RoomDirectoryNavigationViewModel
import javax.inject.Inject

/**
* Simple container for [CreateRoomFragment]
*/
class CreateRoomActivity : VectorBaseActivity(), ToolbarConfigurable {

@Inject lateinit var createRoomViewModelFactory: CreateRoomViewModel.Factory
private val createRoomViewModel: CreateRoomViewModel by viewModel()

private lateinit var navigationViewModel: RoomDirectoryNavigationViewModel

override fun getLayoutRes() = R.layout.activity_simple
@ -46,6 +51,8 @@ class CreateRoomActivity : VectorBaseActivity(), ToolbarConfigurable {
override fun initUiAndData() {
if (isFirstCreation()) {
addFragment(CreateRoomFragment(), R.id.simpleFragmentContainer)

createRoomViewModel.setName(intent?.getStringExtra(INITIAL_NAME) ?: "")
}
}

@ -58,14 +65,19 @@ class CreateRoomActivity : VectorBaseActivity(), ToolbarConfigurable {
navigationViewModel = ViewModelProviders.of(this, viewModelFactory).get(RoomDirectoryNavigationViewModel::class.java)
navigationViewModel.navigateTo.observeEvent(this) { navigation ->
when (navigation) {
is RoomDirectoryActivity.Navigation.Back -> finish()
is RoomDirectoryActivity.Navigation.Back,
is RoomDirectoryActivity.Navigation.Close -> finish()
}
}
}

companion object {
fun getIntent(context: Context): Intent {
return Intent(context, CreateRoomActivity::class.java)
private const val INITIAL_NAME = "INITIAL_NAME"

fun getIntent(context: Context, initialName: String = ""): Intent {
return Intent(context, CreateRoomActivity::class.java).apply {
putExtra(INITIAL_NAME, initialName)
}
}
}


View File

@ -21,7 +21,7 @@ import android.view.MenuItem
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
@ -35,9 +35,8 @@ import javax.inject.Inject
class CreateRoomFragment : VectorBaseFragment(), CreateRoomController.Listener {

private lateinit var navigationViewModel: RoomDirectoryNavigationViewModel
private val viewModel: CreateRoomViewModel by fragmentViewModel()
private val viewModel: CreateRoomViewModel by activityViewModel()
@Inject lateinit var createRoomController: CreateRoomController
@Inject lateinit var createRoomViewModelFactory: CreateRoomViewModel.Factory

override fun getLayoutResId() = R.layout.fragment_create_room


View File

@ -16,6 +16,7 @@

package im.vector.riotx.features.roomdirectory.createroom

import androidx.fragment.app.FragmentActivity
import com.airbnb.mvrx.*
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
@ -25,6 +26,7 @@ import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity

class CreateRoomViewModel @AssistedInject constructor(@Assisted initialState: CreateRoomViewState,
private val session: Session
@ -39,8 +41,13 @@ class CreateRoomViewModel @AssistedInject constructor(@Assisted initialState: Cr

@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: CreateRoomViewState): CreateRoomViewModel? {
val fragment: CreateRoomFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.createRoomViewModelFactory.create(state)
val activity: FragmentActivity = (viewModelContext as ActivityViewModelContext).activity()

return when (activity) {
is CreateRoomActivity -> activity.createRoomViewModelFactory.create(state)
is RoomDirectoryActivity -> activity.createRoomViewModelFactory.create(state)
else -> throw IllegalStateException("Wrong activity")
}
}
}


View File

@ -148,6 +148,7 @@ object VectorPreferences {
private const val SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY = "SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY"

private const val SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY = "SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
private const val SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY = "SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY"

// analytics
const val SETTINGS_USE_ANALYTICS_KEY = "SETTINGS_USE_ANALYTICS_KEY"
@ -249,6 +250,10 @@ object VectorPreferences {
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY, false)
}

fun swipeToReplyIsEnabled(context: Context): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY, true)
}

/**
* Tells if we have already asked the user to disable battery optimisations on android >= M devices.
*

View File

@ -79,9 +79,9 @@ class VectorSettingsActivity : VectorBaseActivity(),
var oFragment: Fragment? = null

if (VectorPreferences.SETTINGS_NOTIFICATION_TROUBLESHOOT_PREFERENCE_KEY == pref?.key) {
oFragment = VectorSettingsNotificationsTroubleshootFragment.newInstance(session.sessionParams.credentials.userId)
oFragment = VectorSettingsNotificationsTroubleshootFragment.newInstance(session.myUserId)
} else if (VectorPreferences.SETTINGS_NOTIFICATION_ADVANCED_PREFERENCE_KEY == pref?.key) {
oFragment = VectorSettingsAdvancedNotificationPreferenceFragment.newInstance(session.sessionParams.credentials.userId)
oFragment = VectorSettingsAdvancedNotificationPreferenceFragment.newInstance(session.myUserId)
} else {
try {
pref?.fragment?.let {

View File

@ -95,7 +95,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {

// Display name
mDisplayNamePreference.let {
it.summary = session.getUser(session.sessionParams.credentials.userId)?.displayName ?: ""
it.summary = session.getUser(session.myUserId)?.displayName ?: ""
it.text = it.summary.toString()
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
onDisplayNameClick(newValue?.let { (it as String).trim() })
@ -148,7 +148,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {

// user account
findPreference(VectorPreferences.SETTINGS_LOGGED_IN_PREFERENCE_KEY)
.summary = session.sessionParams.credentials.userId
.summary = session.myUserId

// home server
findPreference(VectorPreferences.SETTINGS_HOME_SERVER_PREFERENCE_KEY)

View File

@ -367,7 +367,7 @@ class VectorSettingsSecurityPrivacyFragment : VectorSettingsBaseFragment() {
* @param aMyDeviceInfo the device info
*/
private fun refreshCryptographyPreference(aMyDeviceInfo: DeviceInfo?) {
val userId = session.sessionParams.credentials.userId
val userId = session.myUserId
val deviceId = session.sessionParams.credentials.deviceId

// device name

View File

@ -35,7 +35,7 @@ class SignOutUiWorker(private val activity: FragmentActivity) {
activeSessionHolder = context.vectorComponent().activeSessionHolder()
val session = activeSessionHolder.getActiveSession()
if (SignOutViewModel.doYouNeedToBeDisplayed(session)) {
val signOutDialog = SignOutBottomSheetDialogFragment.newInstance(session.sessionParams.credentials.userId)
val signOutDialog = SignOutBottomSheetDialogFragment.newInstance(session.myUserId)
signOutDialog.onSignOut = Runnable {
doSignOut()
}

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M9,9m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#03B381"
android:strokeLineCap="round"/>
<path
android:pathData="M19,19l-4.35,-4.35"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#03B381"
android:strokeLineCap="round"/>
</vector>

View File

@ -14,16 +14,16 @@
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:contentInsetStart="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

<androidx.appcompat.widget.SearchView
android:id="@+id/filteredRoomsSearchView"
style="@style/VectorSearchView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:closeIcon="@drawable/ic_x_green"
app:iconifiedByDefault="false"
app:queryHint="@string/room_filtering_filter_hint"
app:searchIcon="@drawable/ic_filter" />


View File

@ -7,18 +7,6 @@
android:layout_height="wrap_content"
android:padding="8dp">

<TextView
android:id="@+id/quickReactionTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@string/quick_reactions"
android:textColor="?riotx_text_secondary"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="parent" />


<TextView
android:id="@+id/quickReaction0"
android:layout_width="wrap_content"
@ -104,16 +92,14 @@
android:id="@+id/reactionsFlowHelper"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
app:constraint_referenced_ids="quickReaction0,quickReaction1,quickReaction2,quickReaction3,quickReaction4,quickReaction5,quickReaction6,quickReaction7"
app:flow_horizontalGap="8dp"
app:flow_horizontalGap="0dp"
app:flow_horizontalStyle="spread"
app:flow_verticalBias="0"
app:flow_verticalGap="4dp"
app:flow_wrapMode="chain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/quickReactionTitle" />
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -28,6 +28,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
tools:src="@tools:sample/avatars" />

<TextView

View File

@ -11,10 +11,10 @@

<androidx.appcompat.widget.Toolbar
android:id="@+id/createRoomToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="0dp"
android:layout_height="?actionBarSize"
android:elevation="4dp"
app:contentInsetStartWithNavigation="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

View File

@ -8,10 +8,10 @@

<androidx.appcompat.widget.Toolbar
android:id="@+id/groupToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:contentInsetStartWithNavigation="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">


View File

@ -23,36 +23,22 @@

<androidx.appcompat.widget.Toolbar
android:id="@+id/publicRoomsToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
android:minHeight="0dp"
app:contentInsetStartWithNavigation="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap|enterAlways">

<!-- Note: Background is modified in the code for other themes -->
<EditText
<androidx.appcompat.widget.SearchView
android:id="@+id/publicRoomsFilter"
style="@style/VectorSearchView"
android:layout_width="match_parent"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_search_edit_text_light"
android:drawableStart="@drawable/ic_search_white"
android:drawableLeft="@drawable/ic_search_white"
android:drawablePadding="8dp"
android:drawableTint="?riotx_text_secondary"
android:hint="@string/home_filter_placeholder_rooms"
android:lines="1"
android:paddingLeft="8dp"
android:paddingRight="8dp" />
android:layout_height="wrap_content"
app:queryHint="@string/room_directory_search_hint" />

</androidx.appcompat.widget.Toolbar>


View File

@ -8,10 +8,10 @@

<androidx.appcompat.widget.Toolbar
android:id="@+id/roomToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="0dp"
android:layout_height="?actionBarSize"
android:elevation="4dp"
app:contentInsetStartWithNavigation="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

View File

@ -8,7 +8,7 @@
android:background="?riotx_header_panel_background">

<com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/epoxyRecyclerView"
android:id="@+id/roomListEpoxyRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />


View File

@ -13,10 +13,10 @@

<androidx.appcompat.widget.Toolbar
android:id="@+id/roomPreviewNoPreviewToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:elevation="4dp"
app:contentInsetStartWithNavigation="0dp">
android:elevation="4dp">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"

View File

@ -1534,7 +1534,6 @@ Why choose Riot.im?
<string name="settings_other_third_party_notices">Other third party notices</string>
<string name="navigate_to_room_when_already_in_the_room">You are already viewing this room!</string>

<string name="quick_reactions">Quick Reactions</string>

<!-- Settings -->
<string name="settings_general_title">General</string>

View File

@ -32,5 +32,11 @@
<string name="room_filtering_footer_create_new_direct_message">Send a new direct message</string>
<string name="room_filtering_footer_open_room_directory">View the room directory</string>

<string name="room_directory_search_hint">Name or ID (#example:matrix.org)</string>


<string name="labs_swipe_to_reply_in_timeline">Enable swipe to reply in timeline</string>

<string name="link_copied_to_clipboard">Link copied to clipboard</string>

</resources>

View File

@ -9,6 +9,7 @@
<item name="titleTextAppearance">@style/Vector.Toolbar.Title</item>
<item name="subtitleTextAppearance">@style/Vector.Toolbar.SubTitle</item>
<item name="android:background">?riotx_background</item>
<item name="contentInsetStartWithNavigation">0dp</item>
</style>

<style name="VectorToolbarStyle.Group">
@ -160,6 +161,12 @@
<item name="colorControlHighlight">@android:color/white</item>
</style>

<style name="VectorSearchView" parent="Widget.AppCompat.SearchView">
<item name="searchIcon">@drawable/ic_search</item>
<item name="closeIcon">@drawable/ic_x_green</item>
<item name="iconifiedByDefault">false</item>
</style>

<style name="VectorSearches.EditText" parent="Widget.AppCompat.EditText">
<item name="android:textCursorDrawable">@drawable/searches_cursor_background</item>
<item name="android:background">@android:color/transparent</item>

View File

@ -1,45 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">

<!--<im.vector.riotx.core.preference.VectorPreferenceCategory-->
<!--android:key="SETTINGS_LABS_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_pref_title">-->
<!--android:key="SETTINGS_LABS_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_pref_title">-->

<im.vector.riotx.core.preference.VectorPreference
android:focusable="false"
android:key="labs_warning"
android:summary="@string/room_settings_labs_warning_message" />
<im.vector.riotx.core.preference.VectorPreference
android:focusable="false"
android:key="labs_warning"
android:summary="@string/room_settings_labs_warning_message" />

<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_end_to_end" />-->
<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_end_to_end" />-->

<!--<im.vector.riotx.core.preference.VectorPreference-->
<!--android:focusable="false"-->
<!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_end_to_end_is_active" />-->
<!--<im.vector.riotx.core.preference.VectorPreference-->
<!--android:focusable="false"-->
<!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_end_to_end_is_active" />-->

<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY"-->
<!--android:summary="@string/settings_data_save_mode_summary"-->
<!--android:title="@string/settings_data_save_mode" />-->
<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY"-->
<!--android:summary="@string/settings_data_save_mode_summary"-->
<!--android:title="@string/settings_data_save_mode" />-->

<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:defaultValue="true"-->
<!--android:key="SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY"-->
<!--android:title="@string/settings_labs_create_conference_with_jitsi" />-->
<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:defaultValue="true"-->
<!--android:key="SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY"-->
<!--android:title="@string/settings_labs_create_conference_with_jitsi" />-->

<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY"-->
<!--android:summary="@string/settings_labs_enable_send_voice_summary"-->
<!--android:title="@string/settings_labs_enable_send_voice" />-->
<!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY"-->
<!--android:summary="@string/settings_labs_enable_send_voice_summary"-->
<!--android:title="@string/settings_labs_enable_send_voice" />-->

<im.vector.riotx.core.preference.VectorSwitchPreference
android:key="SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
android:defaultValue="false"
android:title="@string/settings_labs_show_hidden_events_in_timeline" />
<im.vector.riotx.core.preference.VectorSwitchPreference
android:defaultValue="false"
android:key="SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
android:title="@string/settings_labs_show_hidden_events_in_timeline" />


<im.vector.riotx.core.preference.VectorSwitchPreference
android:defaultValue="true"
android:key="SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY"
android:title="@string/labs_swipe_to_reply_in_timeline" />

<!--</im.vector.riotx.core.preference.VectorPreferenceCategory>-->