Merge pull request #155 from vector-im/feature/aggregation_local_echo

Local echo for reactions/edits/redacts
This commit is contained in:
Valere 2019-06-04 10:43:22 +02:00 committed by GitHub
commit 9357059cbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 636 additions and 398 deletions

View File

@ -48,7 +48,7 @@ android {
buildConfigField "boolean", "LOG_PRIVATE_DATA", "false"

// Set to BODY instead of NONE to enable logging
buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.NONE"
buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.BASIC"
}

release {

View File

@ -18,12 +18,10 @@ package im.vector.matrix.android.session.room.timeline

import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.InstrumentedTest
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.database.model.SessionRealmModule
import im.vector.matrix.android.internal.session.room.EventRelationExtractor
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.session.room.membership.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline
import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory
@ -60,8 +58,7 @@ internal class TimelineTest : InstrumentedTest {

private fun createTimeline(initialEventId: String? = null): Timeline {
val taskExecutor = TaskExecutor(testCoroutineDispatchers)
val erau = EventRelationsAggregationUpdater(Credentials("", "", "", null, null))
val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy, erau)
val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy)
val paginationTask = FakePaginationTask(tokenChunkEventPersistor)
val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor)
val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID)

View File

@ -21,5 +21,6 @@ data class EditAggregatedSummary(
val aggregatedContent: Content? = null,
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
val sourceEvents: List<String>,
val localEchos: List<String>,
val lastEditTs: Long = 0
)

View File

@ -5,5 +5,6 @@ data class ReactionAggregatedSummary(
val count: Int, // 8
val addedByMe: Boolean, // true
val firstTimestamp: Long, // unix timestamp
val sourceEvents: List<String>
val sourceEvents: List<String>,
val localEchoEvents: List<String>
)

View File

@ -15,13 +15,15 @@ internal object EventAnnotationsSummaryMapper {
it.count,
it.addedByMe,
it.firstTimestamp,
it.sourceEvents.toList()
it.sourceEvents.toList(),
it.sourceLocalEcho.toList()
)
},
editSummary = annotationsSummary.editSummary?.let {
EditAggregatedSummary(
ContentMapper.map(it.aggregatedContent),
it.sourceEvents.toList(),
it.sourceLocalEchoEvents.toList(),
it.lastEditTs
)
}

View File

@ -25,6 +25,7 @@ internal open class EditAggregatedSummaryEntity(
var aggregatedContent: String? = null,
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
var sourceEvents: RealmList<String> = RealmList(),
var sourceLocalEchoEvents: RealmList<String> = RealmList(),
var lastEditTs: Long = 0
) : RealmObject() {


View File

@ -16,7 +16,9 @@ internal open class ReactionAggregatedSummaryEntity(
// The first time this reaction was added (for ordering purpose)
var firstTimestamp: Long = 0,
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
var sourceEvents: RealmList<String> = RealmList()
var sourceEvents: RealmList<String> = RealmList(),
// List of transaction ids for local echos
var sourceLocalEcho: RealmList<String> = RealmList()
) : RealmObject() {

companion object

View File

@ -48,6 +48,7 @@ internal fun EventEntity.Companion.where(realm: Realm,
}
}


internal fun EventEntity.Companion.latestEvent(realm: Realm,
roomId: String,
includedTypes: List<String> = emptyList(),

View File

@ -105,10 +105,6 @@ internal class SessionModule(private val sessionParams: SessionParams) {
RoomSummaryUpdater(get(), get(), get())
}

scope(DefaultSession.SCOPE) {
EventRelationsAggregationUpdater(get())
}

scope(DefaultSession.SCOPE) {
DefaultRoomService(get(), get(), get(), get()) as RoomService
}
@ -168,9 +164,11 @@ internal class SessionModule(private val sessionParams: SessionParams) {

scope(DefaultSession.SCOPE) {
val groupSummaryUpdater = GroupSummaryUpdater(get())
val eventsPruner = EventsPruner(get(), get(), get(), get())
val userEntityUpdater = UserEntityUpdater(get(), get(), get())
listOf<LiveEntityObserver>(groupSummaryUpdater, eventsPruner, userEntityUpdater)
val aggregationUpdater = EventRelationsAggregationUpdater(get(), get(), get(), get())
//Event pruner must be the last one, because it will clear contents
val eventsPruner = EventsPruner(get(), get(), get(), get())
listOf<LiveEntityObserver>(groupSummaryUpdater, userEntityUpdater, aggregationUpdater, eventsPruner)
}



View File

@ -0,0 +1,309 @@
/*
* 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

import arrow.core.Try
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.*
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.relation.ReactionContent
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.mapper.EventMapper
import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.query.create
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.tryTransactionAsync
import io.realm.Realm
import timber.log.Timber

internal interface EventRelationsAggregationTask : Task<EventRelationsAggregationTask.Params, Unit> {

data class Params(
val events: List<Pair<Event, SendState>>,
val userId: String
)
}

/**
* Called by EventRelationAggregationUpdater, when new events that can affect relations are inserted in base.
*/
internal class DefaultEventRelationsAggregationTask(private val monarchy: Monarchy) : EventRelationsAggregationTask {

override fun execute(params: EventRelationsAggregationTask.Params): Try<Unit> {
return monarchy.tryTransactionAsync { realm ->
update(realm, params.events, params.userId)
}
}

private fun update(realm: Realm, events: List<Pair<Event, SendState>>, userId: String) {
events.forEach { pair ->
val roomId = pair.first.roomId ?: return@forEach
val event = pair.first
val sendState = pair.second
val isLocalEcho = sendState == SendState.UNSENT
when (event.type) {
EventType.REACTION -> {
//we got a reaction!!
Timber.v("###REACTION in room $roomId")
handleReaction(event, roomId, realm, userId, isLocalEcho)
}
EventType.MESSAGE -> {
if (event.unsignedData?.relations?.annotations != null) {
Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}")
handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm)
} else {
val content: MessageContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
//A replace!
handleReplace(realm, event, content, roomId, isLocalEcho)
}
}

}
EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
?: return
when (eventToPrune.type) {
EventType.MESSAGE -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
val unsignedData = EventMapper.map(eventToPrune).unsignedData
?: UnsignedData(null, null)

//was this event a m.replace
val contentModel = ContentMapper.map(eventToPrune.content)?.toModel<MessageContent>()
if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
}

}
EventType.REACTION -> {
handleReactionRedact(eventToPrune, realm, userId)
}
}
}
}
}
}

private fun handleReplace(realm: Realm, event: Event, content: MessageContent, roomId: String, isLocalEcho: Boolean) {
val eventId = event.eventId ?: return
val targetEventId = content.relatesTo?.eventId ?: return
val newContent = content.newContent ?: return
//ok, this is a replace
var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst()
if (existing == null) {
Timber.v("###REPLACE creating no relation summary for ${targetEventId}")
existing = EventAnnotationsSummaryEntity.create(realm, targetEventId)
existing.roomId = roomId
}

//we have it
val existingSummary = existing.editSummary
if (existingSummary == null) {
Timber.v("###REPLACE no edit summary for ${targetEventId}, creating one (localEcho:$isLocalEcho)")
//create the edit summary
val editSummary = realm.createObject(EditAggregatedSummaryEntity::class.java)
editSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis()
editSummary.aggregatedContent = ContentMapper.map(newContent)
if (isLocalEcho) {
editSummary.sourceLocalEchoEvents.add(eventId)
} else {
editSummary.sourceEvents.add(eventId)
}

existing.editSummary = editSummary
} else {
if (existingSummary.sourceEvents.contains(eventId)) {
//ignore this event, we already know it (??)
Timber.v("###REPLACE ignoring event for summary, it's known ${eventId}")
return
}
val txId = event.unsignedData?.transactionId
//is it a remote echo?
if (!isLocalEcho && existingSummary.sourceLocalEchoEvents.contains(txId)) {
//ok it has already been managed
Timber.v("###REPLACE Receiving remote echo of edit (edit already done)")
existingSummary.sourceLocalEchoEvents.remove(txId)
existingSummary.sourceEvents.add(event.eventId)
} else if (event.originServerTs ?: 0 > existingSummary.lastEditTs) {
Timber.v("###REPLACE Computing aggregated edit summary (isLocalEcho:$isLocalEcho)")
existingSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis()
existingSummary.aggregatedContent = ContentMapper.map(newContent)
existingSummary.sourceEvents.add(eventId)
} else {
//ignore this event for the summary
Timber.v("###REPLACE ignoring event for summary, it's to old ${eventId}")
}
}

}

private fun handleInitialAggregatedRelations(event: Event, roomId: String, aggregation: AggregatedAnnotation, realm: Realm) {
aggregation.chunk?.forEach {
if (it.type == EventType.REACTION) {
val eventId = event.eventId ?: ""
val existing = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
if (existing == null) {
val eventSummary = EventAnnotationsSummaryEntity.create(realm, eventId)
eventSummary.roomId = roomId
val sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = it.key
sum.firstTimestamp = event.originServerTs ?: 0 //TODO how to maintain order?
sum.count = it.count
eventSummary.reactionsSummary.add(sum)
} else {
//TODO how to handle that
}
}
}
}

private fun handleReaction(event: Event, roomId: String, realm: Realm, userId: String, isLocalEcho: Boolean) {
event.content.toModel<ReactionContent>()?.let { content ->
//rel_type must be m.annotation
if (RelationType.ANNOTATION == content.relatesTo?.type) {
val reaction = content.relatesTo.key
val eventId = content.relatesTo.eventId
val eventSummary = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
?: EventAnnotationsSummaryEntity.create(realm, eventId).apply { this.roomId = roomId }

var sum = eventSummary.reactionsSummary.find { it.key == reaction }
val txId = event.unsignedData?.transactionId
if (isLocalEcho && txId.isNullOrBlank()) {
Timber.w("Received a local echo with no transaction ID")
}
if (sum == null) {
sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = reaction
sum.firstTimestamp = event.originServerTs ?: 0
if (isLocalEcho) {
Timber.v("Adding local echo reaction $reaction")
sum.sourceLocalEcho.add(txId)
sum.count = 1
} else {
Timber.v("Adding synced reaction $reaction")
sum.count = 1
sum.sourceEvents.add(event.eventId)
}
sum.addedByMe = sum.addedByMe || (userId == event.sender)
eventSummary.reactionsSummary.add(sum)
} else {
//is this a known event (is possible? pagination?)
if (!sum.sourceEvents.contains(eventId)) {

//check if it's not the sync of a local echo
if (!isLocalEcho && sum.sourceLocalEcho.contains(txId)) {
//ok it has already been counted, just sync the list, do not touch count
Timber.v("Ignoring synced of local echo for reaction $reaction")
sum.sourceLocalEcho.remove(txId)
sum.sourceEvents.add(event.eventId)
} else {
sum.count += 1
if (isLocalEcho) {
Timber.v("Adding local echo reaction $reaction")
sum.sourceLocalEcho.add(txId)
} else {
Timber.v("Adding synced reaction $reaction")
sum.sourceEvents.add(event.eventId)
}

sum.addedByMe = sum.addedByMe || (userId == event.sender)
}

}
}

}
}
}

/**
* Called when an event is deleted
*/
private fun handleRedactionOfReplace(redacted: EventEntity, relatedEventId: String, realm: Realm) {
Timber.d("Handle redaction of m.replace")
val eventSummary = EventAnnotationsSummaryEntity.where(realm, relatedEventId).findFirst()
if (eventSummary == null) {
Timber.w("Redaction of a replace targeting an unknown event $relatedEventId")
return
}
val sourceEvents = eventSummary.editSummary?.sourceEvents
val sourceToDiscard = sourceEvents?.indexOf(redacted.eventId)
if (sourceToDiscard == null) {
Timber.w("Redaction of a replace that was not known in aggregation $sourceToDiscard")
return
}
//Need to remove this event from the redaction list and compute new aggregation state
sourceEvents.removeAt(sourceToDiscard)
val previousEdit = sourceEvents.mapNotNull { EventEntity.where(realm, it).findFirst() }.sortedBy { it.originServerTs }.lastOrNull()
if (previousEdit == null) {
//revert to original
eventSummary.editSummary?.deleteFromRealm()
} else {
//I have the last event
ContentMapper.map(previousEdit.content)?.toModel<MessageContent>()?.newContent?.let { newContent ->
eventSummary.editSummary?.lastEditTs = previousEdit.originServerTs
?: System.currentTimeMillis()
eventSummary.editSummary?.aggregatedContent = ContentMapper.map(newContent)
} ?: run {
Timber.e("Failed to udate edited summary")
//TODO how to reccover that
}

}
}

fun handleReactionRedact(eventToPrune: EventEntity, realm: Realm, userId: String) {
Timber.v("REDACTION of reaction ${eventToPrune.eventId}")
//delete a reaction, need to update the annotation summary if any
val reactionContent: ReactionContent = EventMapper.map(eventToPrune).content.toModel()
?: return
val eventThatWasReacted = reactionContent.relatesTo?.eventId ?: return

val reactionKey = reactionContent.relatesTo.key
Timber.v("REMOVE reaction for key $reactionKey")
val summary = EventAnnotationsSummaryEntity.where(realm, eventThatWasReacted).findFirst()
if (summary != null) {
summary.reactionsSummary.where()
.equalTo(ReactionAggregatedSummaryEntityFields.KEY, reactionKey)
.findFirst()?.let { aggregation ->
Timber.v("Find summary for key with ${aggregation.sourceEvents.size} known reactions (count:${aggregation.count})")
Timber.v("Known reactions ${aggregation.sourceEvents.joinToString(",")}")
if (aggregation.sourceEvents.contains(eventToPrune.eventId)) {
Timber.v("REMOVE reaction for key $reactionKey")
aggregation.sourceEvents.remove(eventToPrune.eventId)
Timber.v("Known reactions after ${aggregation.sourceEvents.joinToString(",")}")
aggregation.count = aggregation.count - 1
if (eventToPrune.sender == userId) {
//Was it a redact on my reaction?
aggregation.addedByMe = false
}
if (aggregation.count == 0) {
//delete!
aggregation.deleteFromRealm()
}
} else {
Timber.e("## Cannot remove summary from count, corresponding reaction ${eventToPrune.eventId} is not known")
}
}
} else {
Timber.e("## Cannot find summary for key $reactionKey")
}
}
}

View File

@ -15,16 +15,14 @@
*/
package im.vector.matrix.android.internal.session.room

import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.events.model.*
import im.vector.matrix.android.api.session.room.model.relation.ReactionContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.mapper.EventMapper
import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.query.create
import im.vector.matrix.android.internal.database.RealmLiveEntityObserver
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.query.where
import io.realm.Realm
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import timber.log.Timber

/**
@ -32,198 +30,34 @@ import timber.log.Timber
* For reactions will build a EventAnnotationsSummaryEntity, ans for edits a EditAggregatedSummaryEntity.
* The summaries can then be extracted and added (as a decoration) to a TimelineEvent for final display.
*/
internal class EventRelationsAggregationUpdater(private val credentials: Credentials) {
internal class EventRelationsAggregationUpdater(monarchy: Monarchy,
private val credentials: Credentials,
private val task: EventRelationsAggregationTask,
private val taskExecutor: TaskExecutor) :
RealmLiveEntityObserver<EventEntity>(monarchy) {

fun update(realm: Realm, roomId: String, events: List<Event>?) {
events?.forEach { event ->
when (event.type) {
EventType.REACTION -> {
//we got a reaction!!
Timber.v("###REACTION in room $roomId")
handleReaction(event, roomId, realm)
}
EventType.MESSAGE -> {
if (event.unsignedData?.relations?.annotations != null) {
Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}")
handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm)
} else {
val content: MessageContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
//A replace!
handleReplace(event, content, roomId, realm)
}
}
}
}
}
override val query = Monarchy.Query<EventEntity> {
EventEntity.where(it)
//mmm why is this query not working?
// EventEntity.byTypes(it, listOf(
// EventType.REDACTION, EventType.MESSAGE, EventType.REDACTION)
// )
}

private fun handleReplace(event: Event, content: MessageContent, roomId: String, realm: Realm) {
val eventId = event.eventId ?: return
val targetEventId = content.relatesTo?.eventId ?: return
val newContent = content.newContent ?: return
//ok, this is a replace
var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst()
if (existing == null) {
Timber.v("###REPLACE creating no relation summary for ${targetEventId}")
existing = EventAnnotationsSummaryEntity.create(realm, targetEventId)
existing.roomId = roomId
}
override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
Timber.v("EventRelationsAggregationUpdater called with ${inserted.size} insertions")
val inserted = inserted
.mapNotNull { it.asDomain() to it.sendState }

//we have it
val existingSummary = existing.editSummary
if (existingSummary == null) {
Timber.v("###REPLACE no edit summary for ${targetEventId}, creating one")
//create the edit summary
val editSummary = realm.createObject(EditAggregatedSummaryEntity::class.java)
editSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis()
editSummary.aggregatedContent = ContentMapper.map(newContent)
editSummary.sourceEvents.add(eventId)
val params = EventRelationsAggregationTask.Params(
inserted,
credentials.userId
)

existing.editSummary = editSummary
} else {
if (existingSummary.sourceEvents.contains(eventId)) {
//ignore this event, we already know it (??)
Timber.v("###REPLACE ignoring event for summary, it's known ${eventId}")
return
}
//This message has already been edited
if (event.originServerTs ?: 0 > existingSummary.lastEditTs ?: 0) {
Timber.v("###REPLACE Computing aggregated edit summary")
existingSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis()
existingSummary.aggregatedContent = ContentMapper.map(newContent)
existingSummary.sourceEvents.add(eventId)
} else {
//ignore this event for the summary
Timber.v("###REPLACE ignoring event for summary, it's to old ${eventId}")
}
}
task.configureWith(params)
.executeBy(taskExecutor)

}

private fun handleInitialAggregatedRelations(event: Event, roomId: String, aggregation: AggregatedAnnotation, realm: Realm) {
aggregation.chunk?.forEach {
if (it.type == EventType.REACTION) {
val eventId = event.eventId ?: ""
val existing = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
if (existing == null) {
val eventSummary = EventAnnotationsSummaryEntity.create(realm, eventId)
eventSummary.roomId = roomId
val sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = it.key
sum.firstTimestamp = event.originServerTs ?: 0 //TODO how to maintain order?
sum.count = it.count
eventSummary.reactionsSummary.add(sum)
} else {
//TODO how to handle that
}
}
}
}
}

fun handleReaction(event: Event, roomId: String, realm: Realm) {
event.content.toModel<ReactionContent>()?.let { content ->
//rel_type must be m.annotation
if (RelationType.ANNOTATION == content.relatesTo?.type) {
val reaction = content.relatesTo.key
val eventId = content.relatesTo.eventId
val eventSummary = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
?: EventAnnotationsSummaryEntity.create(realm, eventId).apply { this.roomId = roomId }

var sum = eventSummary.reactionsSummary.find { it.key == reaction }
if (sum == null) {
sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = reaction
sum.firstTimestamp = event.originServerTs ?: 0
sum.count = 1
sum.sourceEvents.add(event.eventId)
sum.addedByMe = sum.addedByMe || (credentials.userId == event.sender)
eventSummary.reactionsSummary.add(sum)
} else {
//is this a known event (is possible? pagination?)
if (!sum.sourceEvents.contains(eventId)) {
sum.count += 1
sum.sourceEvents.add(event.eventId)
sum.addedByMe = sum.addedByMe || (credentials.userId == event.sender)
}
}

}
}
}

/**
* Called when an event is deleted
*/
fun handleRedactionOfReplace(redacted: EventEntity, relatedEventId: String, realm: Realm) {
Timber.d("Handle redaction of m.replace")
val eventSummary = EventAnnotationsSummaryEntity.where(realm, relatedEventId).findFirst()
if (eventSummary == null) {
Timber.w("Redaction of a replace targeting an unknown event $relatedEventId")
return
}
val sourceEvents = eventSummary.editSummary?.sourceEvents
val sourceToDiscard = sourceEvents?.indexOf(redacted.eventId)
if (sourceToDiscard == null) {
Timber.w("Redaction of a replace that was not known in aggregation $sourceToDiscard")
return
}
//Need to remove this event from the redaction list and compute new aggregation state
sourceEvents.removeAt(sourceToDiscard)
val previousEdit = sourceEvents.mapNotNull { EventEntity.where(realm, it).findFirst() }.sortedBy { it.originServerTs }.lastOrNull()
if (previousEdit == null) {
//revert to original
eventSummary.editSummary?.deleteFromRealm()
} else {
//I have the last event
ContentMapper.map(previousEdit.content)?.toModel<MessageContent>()?.newContent?.let { newContent ->
eventSummary.editSummary?.lastEditTs = previousEdit.originServerTs
?: System.currentTimeMillis()
eventSummary.editSummary?.aggregatedContent = ContentMapper.map(newContent)
} ?: run {
Timber.e("Failed to udate edited summary")
//TODO how to reccover that
}

}
}

fun handleReactionRedact(eventToPrune: EventEntity, realm: Realm, userId: String) {
Timber.d("REDACTION of reaction ${eventToPrune.eventId}")
//delete a reaction, need to update the annotation summary if any
val reactionContent: ReactionContent = EventMapper.map(eventToPrune).content.toModel()
?: return
val eventThatWasReacted = reactionContent.relatesTo?.eventId ?: return

val reactionkey = reactionContent.relatesTo.key
Timber.d("REMOVE reaction for key $reactionkey")
val summary = EventAnnotationsSummaryEntity.where(realm, eventThatWasReacted).findFirst()
if (summary != null) {
summary.reactionsSummary.where()
.equalTo(ReactionAggregatedSummaryEntityFields.KEY, reactionkey)
.findFirst()?.let { summary ->
Timber.d("Find summary for key with ${summary.sourceEvents.size} known reactions (count:${summary.count})")
Timber.d("Known reactions ${summary.sourceEvents.joinToString(",")}")
if (summary.sourceEvents.contains(eventToPrune.eventId)) {
Timber.d("REMOVE reaction for key $reactionkey")
summary.sourceEvents.remove(eventToPrune.eventId)
Timber.d("Known reactions after ${summary.sourceEvents.joinToString(",")}")
summary.count = summary.count - 1
if (eventToPrune.sender == userId) {
//Was it a redact on my reaction?
summary.addedByMe = false
}
if (summary.count == 0) {
//delete!
summary.deleteFromRealm()
}
} else {
Timber.e("## Cannot remove summary from count, corresponding reaction ${eventToPrune.eventId} is not known")
}
}
} else {
Timber.e("## Cannot find summary for key $reactionkey")
}
}
}

View File

@ -17,10 +17,6 @@
package im.vector.matrix.android.internal.session.room

import im.vector.matrix.android.internal.session.DefaultSession
import im.vector.matrix.android.internal.session.room.relation.DefaultFindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.relation.DefaultUpdateQuickReactionTask
import im.vector.matrix.android.internal.session.room.relation.FindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.relation.UpdateQuickReactionTask
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
import im.vector.matrix.android.internal.session.room.create.DefaultCreateRoomTask
import im.vector.matrix.android.internal.session.room.membership.DefaultLoadRoomMembersTask
@ -35,6 +31,10 @@ import im.vector.matrix.android.internal.session.room.prune.DefaultPruneEventTas
import im.vector.matrix.android.internal.session.room.prune.PruneEventTask
import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.session.room.relation.DefaultFindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.relation.DefaultUpdateQuickReactionTask
import im.vector.matrix.android.internal.session.room.relation.FindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.relation.UpdateQuickReactionTask
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask
import im.vector.matrix.android.internal.session.room.state.SendStateTask
@ -57,7 +57,7 @@ class RoomModule {
}

scope(DefaultSession.SCOPE) {
TokenChunkEventPersistor(get(), get())
TokenChunkEventPersistor(get())
}

scope(DefaultSession.SCOPE) {
@ -109,7 +109,11 @@ class RoomModule {
}

scope(DefaultSession.SCOPE) {
DefaultPruneEventTask(get(), get()) as PruneEventTask
DefaultPruneEventTask(get()) as PruneEventTask
}

scope(DefaultSession.SCOPE) {
DefaultEventRelationsAggregationTask(get()) as EventRelationsAggregationTask
}

}

View File

@ -25,8 +25,12 @@ import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import timber.log.Timber


/**
* Listens to the database for the insertion of any redaction event.
* As it will actually delete the content, it should be called last in the list of listener.
*/
internal class EventsPruner(monarchy: Monarchy,
private val credentials: Credentials,
private val pruneEventTask: PruneEventTask,
@ -36,6 +40,7 @@ internal class EventsPruner(monarchy: Monarchy,
override val query = Monarchy.Query<EventEntity> { EventEntity.where(it, type = EventType.REDACTION) }

override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
Timber.v("Event pruner called with ${inserted.size} insertions")
val redactionEvents = inserted
.mapNotNull { it.asDomain() }


View File

@ -17,14 +17,15 @@ package im.vector.matrix.android.internal.session.room.prune

import arrow.core.Try
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.*
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.UnsignedData
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.mapper.EventMapper
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.Realm
@ -41,8 +42,7 @@ internal interface PruneEventTask : Task<PruneEventTask.Params, Unit> {
}

internal class DefaultPruneEventTask(
private val monarchy: Monarchy,
private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) : PruneEventTask {
private val monarchy: Monarchy) : PruneEventTask {

override fun execute(params: PruneEventTask.Params): Try<Unit> {
return monarchy.tryTransactionSync { realm ->
@ -57,6 +57,12 @@ internal class DefaultPruneEventTask(
return
}

val redactionEventEntity = EventEntity.where(realm, eventId = redactionEvent.eventId
?: "").findFirst()
?: return
val isLocalEcho = redactionEventEntity.sendState == SendState.UNSENT
Timber.v("Redact event for ${redactionEvent.redacts} localEcho=$isLocalEcho")

val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst()
?: return

@ -72,19 +78,19 @@ internal class DefaultPruneEventTask(
?: UnsignedData(null, null)

//was this event a m.replace
val contentModel = ContentMapper.map(eventToPrune.content)?.toModel<MessageContent>()
if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
eventRelationsAggregationUpdater.handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
}
// val contentModel = ContentMapper.map(eventToPrune.content)?.toModel<MessageContent>()
// if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
// eventRelationsAggregationUpdater.handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
// }

val modified = unsignedData.copy(redactedEvent = redactionEvent)
eventToPrune.content = ContentMapper.map(emptyMap())
eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified)

}
EventType.REACTION -> {
eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId)
}
// EventType.REACTION -> {
// eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId)
// }
}
}
}

View File

@ -15,12 +15,12 @@
*/
package im.vector.matrix.android.internal.session.room.relation

import androidx.work.*
import androidx.work.OneTimeWorkRequest
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.relation.RelationService
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.util.Cancelable
import im.vector.matrix.android.internal.database.helper.addSendingEvent
import im.vector.matrix.android.internal.database.model.ChunkEntity
@ -30,19 +30,14 @@ import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.send.RedactEventWorker
import im.vector.matrix.android.internal.session.room.send.SendEventWorker
import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionAsync
import java.util.concurrent.TimeUnit
import timber.log.Timber

private const val REACTION_WORK = "REACTION_WORK"
private const val BACKOFF_DELAY = 10_000L

private val WORK_CONSTRAINTS = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

internal class DefaultRelationService(private val roomId: String,
private val eventFactory: LocalEchoEventFactory,
@ -55,28 +50,22 @@ internal class DefaultRelationService(private val roomId: String,

override fun sendReaction(reaction: String, targetEventId: String): Cancelable {
val event = eventFactory.createReactionEvent(roomId, targetEventId, reaction)
// .also {
// //saveLocalEcho(it)
// }
.also {
saveLocalEcho(it)
}
val sendRelationWork = createSendRelationWork(event)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, sendRelationWork)
.enqueue()
TimelineSendEventWorkCommon.postWork(roomId, sendRelationWork)
return CancelableWork(sendRelationWork.id)
}


private fun createSendRelationWork(event: Event): OneTimeWorkRequest {
//TODO use the new API to send relation (for now use regular send)
val sendContentWorkerParams = SendEventWorker.Params(
roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
return TimelineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData)

}

override fun undoReaction(reaction: String, targetEventId: String, myUserId: String)/*: Cancelable*/ {
@ -91,11 +80,19 @@ internal class DefaultRelationService(private val roomId: String,
.enableRetry()
.dispatchTo(object : MatrixCallback<FindReactionEventForUndoTask.Result> {
override fun onSuccess(data: FindReactionEventForUndoTask.Result) {
if (data.redactEventId == null) {
Timber.w("Cannot find reaction to undo (not yet synced?)")
//TODO?
}
data.redactEventId?.let { toRedact ->
val redactWork = createRedactEventWork(toRedact, null)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, redactWork)
.enqueue()

val redactEvent = eventFactory.createRedactEvent(roomId, toRedact, null).also {
saveLocalEcho(it)
}
val redactWork = createRedactEventWork(redactEvent, toRedact, null)

TimelineSendEventWorkCommon.postWork(roomId, redactWork)

}
}
})
@ -119,10 +116,11 @@ internal class DefaultRelationService(private val roomId: String,
override fun onSuccess(data: UpdateQuickReactionTask.Result) {
data.reactionToAdd?.also { sendReaction(it, targetEventId) }
data.reactionToRedact.forEach {
val redactWork = createRedactEventWork(it, null)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, redactWork)
.enqueue()
val redactEvent = eventFactory.createRedactEvent(roomId, it, null).also {
saveLocalEcho(it)
}
val redactWork = createRedactEventWork(redactEvent, it, null)
TimelineSendEventWorkCommon.postWork(roomId, redactWork)
}
}
})
@ -133,48 +131,26 @@ internal class DefaultRelationService(private val roomId: String,
return "${roomId}_$identifier"
}

// private fun saveLocalEcho(event: Event) {
// monarchy.tryTransactionAsync { realm ->
// val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
// ?: return@tryTransactionAsync
// val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId)
// ?: return@tryTransactionAsync
//
// roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0)
// }
// }

//TODO duplicate with send service?
private fun createRedactEventWork(eventId: String, reason: String?): OneTimeWorkRequest {
private fun createRedactEventWork(localEvent: Event, eventId: String, reason: String?): OneTimeWorkRequest {

//TODO create local echo of m.room.redaction event?

val sendContentWorkerParams = RedactEventWorker.Params(
val sendContentWorkerParams = RedactEventWorker.Params(localEvent.eventId!!,
roomId, eventId, reason)
val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<RedactEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(redactWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
return TimelineSendEventWorkCommon.createWork<RedactEventWorker>(redactWorkData)
}

override fun editTextMessage(targetEventId: String, newBodyText: String, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String): Cancelable {
val event = eventFactory.createReplaceTextEvent(roomId, targetEventId, newBodyText, newBodyAutoMarkdown, MessageType.MSGTYPE_TEXT, compatibilityBodyText)
val event = eventFactory.createReplaceTextEvent(roomId, targetEventId, newBodyText, newBodyAutoMarkdown, MessageType.MSGTYPE_TEXT, compatibilityBodyText).also {
saveLocalEcho(it)
}
val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

//TODO use relation API?
val workRequest = OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()

WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, workRequest)
.enqueue()
val workRequest = TimelineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData)
TimelineSendEventWorkCommon.postWork(roomId, workRequest)
return CancelableWork(workRequest.id)

}
@ -186,18 +162,19 @@ internal class DefaultRelationService(private val roomId: String,
} ?: return null
val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)
val workRequest = OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()

WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, workRequest)
.enqueue()

val workRequest = TimelineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData)
TimelineSendEventWorkCommon.postWork(roomId, workRequest)
return CancelableWork(workRequest.id)
}

/**
* Saves the event in database as a local echo.
* SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room.
* The sendingTimelineEvents is checked on new sync and will remove the local echo if an event with
* the same transaction id is received (in unsigned data)
*/
private fun saveLocalEcho(event: Event) {
monarchy.tryTransactionAsync { realm ->
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()

View File

@ -70,7 +70,11 @@ class SendRelationWorker(context: Context, params: WorkerParameters)
return result.fold({
when (it) {
is Failure.NetworkConnection -> Result.retry()
else -> Result.failure()
else -> {
//TODO mark as failed to send?
//always return success, or the chain will be stuck for ever!
Result.success()
}
}
}, { Result.success() })
}

View File

@ -24,18 +24,12 @@ import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.api.util.addTo
import im.vector.matrix.android.internal.database.helper.addSendingEvent
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.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.content.UploadContentWorker
import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon
import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionAsync
import java.util.concurrent.TimeUnit

private const val SEND_WORK = "SEND_WORK"
private const val UPLOAD_WORK = "UPLOAD_WORK"
private const val BACKOFF_DELAY = 10_000L

@ -54,9 +48,7 @@ internal class DefaultSendService(private val roomId: String,
saveLocalEcho(it)
}
val sendWork = createSendEventWork(event)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, sendWork)
.enqueue()
TimelineSendEventWorkCommon.postWork(roomId, sendWork)
return CancelableWork(sendWork.id)
}

@ -65,9 +57,7 @@ internal class DefaultSendService(private val roomId: String,
saveLocalEcho(it)
}
val sendWork = createSendEventWork(event)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, sendWork)
.enqueue()
TimelineSendEventWorkCommon.postWork(roomId, sendWork)
return CancelableWork(sendWork.id)
}

@ -80,12 +70,9 @@ internal class DefaultSendService(private val roomId: String,
}

override fun redactEvent(event: Event, reason: String?): Cancelable {
//TODO manage local echo ?
//TODO manage media/attachements?
val redactWork = createRedactEventWork(event, reason)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, redactWork)
.enqueue()
TimelineSendEventWorkCommon.postWork(roomId, redactWork)
return CancelableWork(redactWork.id)
}

@ -106,14 +93,7 @@ internal class DefaultSendService(private val roomId: String,
}

private fun saveLocalEcho(event: Event) {
monarchy.tryTransactionAsync { realm ->
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
?: return@tryTransactionAsync
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId)
?: return@tryTransactionAsync

roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0)
}
eventFactory.saveLocalEcho(monarchy, event)
}

private fun buildWorkIdentifier(identifier: String): String {
@ -124,26 +104,20 @@ internal class DefaultSendService(private val roomId: String,
val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
return TimelineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData)
}

private fun createRedactEventWork(event: Event, reason: String?): OneTimeWorkRequest {

//TODO create local echo of m.room.redaction event?
val redactEvent = eventFactory.createRedactEvent(roomId, event.eventId!!, reason).also {
saveLocalEcho(it)
}

val sendContentWorkerParams = RedactEventWorker.Params(
roomId, event.eventId!!, reason)
val sendContentWorkerParams = RedactEventWorker.Params(redactEvent.eventId!!,
roomId, event.eventId, reason)
val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<RedactEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(redactWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
return TimelineSendEventWorkCommon.createWork<RedactEventWorker>(redactWorkData)
}

private fun createUploadMediaWork(event: Event, attachment: ContentAttachmentData): OneTimeWorkRequest {

View File

@ -17,22 +17,38 @@
package im.vector.matrix.android.internal.session.room.send

import android.media.MediaMetadataRetriever
import android.text.TextUtils
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.R
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.*
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.model.relation.ReactionContent
import im.vector.matrix.android.api.session.room.model.relation.ReactionInfo
import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent
import im.vector.matrix.android.api.session.room.model.relation.ReplyToContent
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.internal.database.helper.addSendingEvent
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.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.content.ThumbnailExtractor
import im.vector.matrix.android.internal.util.StringProvider
import im.vector.matrix.android.internal.util.tryTransactionAsync
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import java.util.*

/**
* Creates local echo of events for room events.
* A local echo is an event that is persisted even if not yet sent to the server,
* in an optimistic way (as if the server as responded immediately). Local echo are using a local id,
* (the transaction ID), this id is used when receiving an event from a sync to check if this event
* is matching an existing local echo.
*
* The transactionID is used as loc
*/
internal class LocalEchoEventFactory(private val credentials: Credentials, private val stringProvider: StringProvider) {

fun createTextEvent(roomId: String, msgType: String, text: String, autoMarkdown: Boolean): Event {
@ -41,7 +57,7 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
val document = parser.parse(text)
val renderer = HtmlRenderer.builder().build()
val htmlText = renderer.render(document)
if (!TextUtils.equals(text, htmlText)) {
if (isFormattedTextPertinent(text, htmlText)) { //FIXME
return createFormattedTextEvent(roomId, text, htmlText)
}
}
@ -49,6 +65,9 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
return createEvent(roomId, content)
}

private fun isFormattedTextPertinent(text: String, htmlText: String?) =
text != htmlText && htmlText != "<p>$text</p>\n"

fun createFormattedTextEvent(roomId: String, text: String, formattedText: String): Event {
val content = MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
@ -71,7 +90,7 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
val document = parser.parse(newBodyText)
val renderer = HtmlRenderer.builder().build()
val htmlText = renderer.render(document)
if (!TextUtils.equals(newBodyText, htmlText)) {
if (isFormattedTextPertinent(newBodyText, htmlText)) {
newContent = MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
format = MessageType.FORMAT_MATRIX_HTML,
@ -95,7 +114,7 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment)
ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment)
ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment)
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment)
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment)
}
}

@ -107,14 +126,16 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
reaction
)
)
val localId = dummyEventId(roomId)
return Event(
roomId = roomId,
originServerTs = dummyOriginServerTs(),
sender = credentials.userId,
eventId = dummyEventId(roomId),
eventId = localId,
type = EventType.REACTION,
content = content.toContent()
)
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))

}


@ -196,13 +217,15 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
}

private fun createEvent(roomId: String, content: Any? = null): Event {
val localID = dummyEventId(roomId)
return Event(
roomId = roomId,
originServerTs = dummyOriginServerTs(),
sender = credentials.userId,
eventId = dummyEventId(roomId),
eventId = localID,
type = EventType.MESSAGE,
content = content.toContent()
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localID)
)
}

@ -211,7 +234,7 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
}

private fun dummyEventId(roomId: String): String {
return roomId + "-" + dummyOriginServerTs()
return "m.${UUID.randomUUID()}"
}

fun createReplyTextEvent(roomId: String, eventReplied: Event, replyText: String): Event? {
@ -276,13 +299,55 @@ internal class LocalEchoEventFactory(private val credentials: Credentials, priva
}
return content.body to formattedText
}
MessageType.MSGTYPE_FILE -> return stringProvider.getString(R.string.reply_to_a_file) to null
MessageType.MSGTYPE_AUDIO -> return stringProvider.getString(R.string.reply_to_an_audio_file) to null
MessageType.MSGTYPE_IMAGE -> return stringProvider.getString(R.string.reply_to_an_image) to null
MessageType.MSGTYPE_VIDEO -> return stringProvider.getString(R.string.reply_to_a_video) to null
else -> return (content?.body ?: "") to null
MessageType.MSGTYPE_FILE -> return stringProvider.getString(R.string.reply_to_a_file) to null
MessageType.MSGTYPE_AUDIO -> return stringProvider.getString(R.string.reply_to_an_audio_file) to null
MessageType.MSGTYPE_IMAGE -> return stringProvider.getString(R.string.reply_to_an_image) to null
MessageType.MSGTYPE_VIDEO -> return stringProvider.getString(R.string.reply_to_a_video) to null
else -> return (content?.body ?: "") to null

}

}

/*
* {
"content": {
"reason": "Spamming"
},
"event_id": "$143273582443PhrSn:domain.com",
"origin_server_ts": 1432735824653,
"redacts": "$fukweghifu23:localhost",
"room_id": "!jEsUZKDJdhlrceRyVU:domain.com",
"sender": "@example:domain.com",
"type": "m.room.redaction",
"unsigned": {
"age": 1234
}
}
*/
fun createRedactEvent(roomId: String, eventId: String, reason: String?): Event {
val localID = dummyEventId(roomId)
return Event(
roomId = roomId,
originServerTs = dummyOriginServerTs(),
sender = credentials.userId,
eventId = localID,
type = EventType.REDACTION,
redacts = eventId,
content = reason?.let { mapOf("reason" to it).toContent() },
unsignedData = UnsignedData(age = null, transactionId = localID)
)
}

fun saveLocalEcho(monarchy: Monarchy, event: Event) {
monarchy.tryTransactionAsync { realm ->
val roomEntity = RoomEntity.where(realm, roomId = event.roomId!!).findFirst()
?: return@tryTransactionAsync
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = event.roomId)
?: return@tryTransactionAsync

roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0)
}
}

}

View File

@ -25,13 +25,13 @@ import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import org.koin.standalone.inject
import java.util.*

internal class RedactEventWorker(context: Context, params: WorkerParameters)
: Worker(context, params), MatrixKoinComponent {

@JsonClass(generateAdapter = true)
internal data class Params(
val txID: String,
val roomId: String,
val eventId: String,
val reason: String?
@ -40,26 +40,26 @@ internal class RedactEventWorker(context: Context, params: WorkerParameters)
private val roomAPI by inject<RoomAPI>()

override fun doWork(): Result {
val params = WorkerParamsFactory.fromData<RedactEventWorker.Params>(inputData)
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure()

if (params.eventId == null) {
return Result.failure()
}
val txID = UUID.randomUUID().toString()

val eventId = params.eventId
val result = executeRequest<SendResponse> {
apiCall = roomAPI.redactEvent(
txID,
params.txID,
params.roomId,
params.eventId,
eventId,
if (params.reason == null) emptyMap() else mapOf("reason" to params.reason)
)
}
return result.fold({
when (it) {
is Failure.NetworkConnection -> Result.retry()
else -> Result.failure()
else -> {
//TODO mark as failed to send?
//always return success, or the chain will be stuck for ever!
Result.success()
}
}
}, {
Result.success()

View File

@ -61,7 +61,11 @@ internal class SendEventWorker(context: Context, params: WorkerParameters)
return result.fold({
when (it) {
is Failure.NetworkConnection -> Result.retry()
else -> Result.failure()
else -> {
//TODO mark as failed to send?
//always return success, or the chain will be stuck for ever!
Result.success()
}
}
}, { Result.success() })
}

View File

@ -0,0 +1,56 @@
/*
* 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.timeline

import androidx.work.*
import java.util.concurrent.TimeUnit


private const val SEND_WORK = "SEND_WORK"
private const val BACKOFF_DELAY = 10_000L

private val WORK_CONSTRAINTS = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

/**
* Helper class for sending event related works.
* All send event from a room are using the same workchain, in order to ensure order.
* WorkRequest must always return success (even if server error, in this case marking the event as failed to send)
* , if not the chain will be doomed in failed state.
*
*/
internal object TimelineSendEventWorkCommon {

fun postWork(roomId: String, workRequest: OneTimeWorkRequest) {
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(roomId), ExistingWorkPolicy.APPEND, workRequest)
.enqueue()

}

inline fun <reified W : ListenableWorker> createWork(data: Data): OneTimeWorkRequest {
return OneTimeWorkRequestBuilder<W>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(data)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}

private fun buildWorkIdentifier(roomId: String): String {
return "${roomId}_$SEND_WORK"
}
}

View File

@ -18,20 +18,13 @@ 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.mapper.EventMapper
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.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.kotlin.createObject
import timber.log.Timber
@ -39,8 +32,7 @@ import timber.log.Timber
/**
* Insert Chunk in DB, and eventually merge with existing chunk event
*/
internal class TokenChunkEventPersistor(private val monarchy: Monarchy,
private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) {
internal class TokenChunkEventPersistor(private val monarchy: Monarchy) {

/**
* <pre>
@ -119,7 +111,7 @@ internal class TokenChunkEventPersistor(private val monarchy: 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?
@ -142,7 +134,7 @@ internal class TokenChunkEventPersistor(private val monarchy: 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")
@ -151,8 +143,6 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy,
Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken} | ${currentChunk.prevToken}")
currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked())

//Event
eventRelationsAggregationUpdater.update(realm,roomId,receivedChunk.events.toList())
// Then we merge chunks if needed
if (currentChunk != prevChunk && prevChunk != null) {
currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk)

View File

@ -31,14 +31,9 @@ import im.vector.matrix.android.internal.database.model.RoomEntity
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
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
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.InvitedRoomSync
import im.vector.matrix.android.internal.session.sync.model.RoomSync
import im.vector.matrix.android.internal.session.sync.model.RoomSyncAccountData
import im.vector.matrix.android.internal.session.sync.model.RoomSyncEphemeral
import im.vector.matrix.android.internal.session.sync.model.RoomsSyncResponse
import im.vector.matrix.android.internal.session.sync.model.*
import io.realm.Realm
import io.realm.kotlin.createObject
import timber.log.Timber
@ -46,8 +41,7 @@ import timber.log.Timber
internal class RoomSyncHandler(private val monarchy: Monarchy,
private val readReceiptHandler: ReadReceiptHandler,
private val roomSummaryUpdater: RoomSummaryUpdater,
private val roomTagHandler: RoomTagHandler,
private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) {
private val roomTagHandler: RoomTagHandler) {

sealed class HandlingStrategy {
data class JOINED(val data: Map<String, RoomSync>) : HandlingStrategy()
@ -67,9 +61,9 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,

private fun handleRoomSync(realm: Realm, handlingStrategy: HandlingStrategy) {
val rooms = when (handlingStrategy) {
is HandlingStrategy.JOINED -> handlingStrategy.data.map { handleJoinedRoom(realm, it.key, it.value) }
is HandlingStrategy.JOINED -> handlingStrategy.data.map { handleJoinedRoom(realm, it.key, it.value) }
is HandlingStrategy.INVITED -> handlingStrategy.data.map { handleInvitedRoom(realm, it.key, it.value) }
is HandlingStrategy.LEFT -> handlingStrategy.data.map { handleLeftRoom(realm, it.key, it.value) }
is HandlingStrategy.LEFT -> handlingStrategy.data.map { handleLeftRoom(realm, it.key, it.value) }
}
realm.insertOrUpdate(rooms)
}
@ -81,7 +75,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
Timber.v("Handle join sync for room $roomId")

val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId)
?: realm.createObject(roomId)

if (roomEntity.membership == Membership.INVITE) {
roomEntity.chunks.deleteAllFromRealm()
@ -116,11 +110,13 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
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")
}
}
}
eventRelationsAggregationUpdater.update(realm, roomId, roomSync.timeline?.events)
roomSummaryUpdater.update(realm, roomId, Membership.JOIN, roomSync.summary, roomSync.unreadNotifications)

if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) {
@ -139,7 +135,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
InvitedRoomSync): RoomEntity {
Timber.v("Handle invited sync for room $roomId")
val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId)
?: realm.createObject(roomId)
roomEntity.membership = Membership.INVITE
if (roomSync.inviteState != null && roomSync.inviteState.events.isNotEmpty()) {
val chunkEntity = handleTimelineEvents(realm, roomId, roomSync.inviteState.events)
@ -153,7 +149,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
roomId: String,
roomSync: RoomSync): RoomEntity {
val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId)
?: realm.createObject(roomId)

roomEntity.membership = Membership.LEAVE
roomEntity.chunks.deleteAllFromRealm()

View File

@ -40,7 +40,7 @@ internal class SyncModule {
}

scope(DefaultSession.SCOPE) {
RoomSyncHandler(get(), get(), get(), get(), get())
RoomSyncHandler(get(), get(), get(), get())
}

scope(DefaultSession.SCOPE) {

View File

@ -90,7 +90,9 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
avatarUrl = avatarUrl,
memberName = formattedMemberName,
showInformation = showInformation,
orderedReactionList = event.annotations?.reactionsSummary?.map { Triple(it.key, it.count, it.addedByMe) },
orderedReactionList = event.annotations?.reactionsSummary?.map {
ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty())
},
hasBeenEdited = hasBeenEdited
)


View File

@ -112,11 +112,12 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
(holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton ->
reactionButton.isVisible = true
reactionButton.reactedListener = reactionClickListener
reactionButton.setTag(R.id.messageBottomInfo, reaction.first)
reactionButton.setTag(R.id.messageBottomInfo, reaction.key)
idToRefInFlow.add(reactionButton.id)
reactionButton.reactionString = reaction.first
reactionButton.reactionCount = reaction.second
reactionButton.setChecked(reaction.third)
reactionButton.reactionString = reaction.key
reactionButton.reactionCount = reaction.count
reactionButton.setChecked(reaction.addedByMe)
reactionButton.isEnabled = reaction.synced
}
}
// Just setting the view as gone will break the FlowHelper (and invisible will take too much space),

View File

@ -16,9 +16,8 @@

package im.vector.riotredesign.features.home.room.detail.timeline.item

import im.vector.matrix.android.api.session.room.send.SendState

import android.os.Parcelable
import im.vector.matrix.android.api.session.room.send.SendState
import kotlinx.android.parcel.Parcelize

@Parcelize
@ -31,6 +30,15 @@ data class MessageInformationData(
val memberName: CharSequence? = null,
val showInformation: Boolean = true,
/*List of reactions (emoji,count,isSelected)*/
var orderedReactionList: List<Triple<String,Int,Boolean>>? = null,
var orderedReactionList: List<ReactionInfoData>? = null,
var hasBeenEdited: Boolean = false
) : Parcelable
) : Parcelable


@Parcelize
data class ReactionInfoData(
val key: String,
val count: Int,
val addedByMe: Boolean,
val synced: Boolean
) : Parcelable