diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ThreadToReplyMapInterceptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ThreadToReplyMapInterceptor.kt new file mode 100644 index 0000000000..519e497e95 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ThreadToReplyMapInterceptor.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.network + +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.network.ApiInterceptorListener +import org.matrix.android.sdk.api.network.ApiPath +import org.matrix.android.sdk.internal.di.MatrixScope +import timber.log.Timber +import javax.inject.Inject + +/** + * The goal of this interceptor is to map thread events to be handled as replies. + * The interceptor is responsible for mapping a thread event: + * "m.relates_to":{ + * "event_id":"$eventId", + * "rel_type":"io.element.thread" + * } + * to an equivalent reply event: + * m.relates_to":{ + * "m.in_reply_to":{ + * "event_id":"$eventId" + * } + */ +@MatrixScope +internal class ThreadToReplyMapInterceptor @Inject constructor() : Interceptor { + + init { + Timber.d("MapThreadToReplyInterceptor.init") + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val response = chain.proceed(request) + + if (isSyncRequest(request)) { + Timber.i(" ------> found SYNC REQUEST") + + return response.body?.let { + val contentType = it.contentType() + val rawBody = it.string() + Timber.i(" ------> $rawBody") + + if(rawBody.contains("\"rel_type\":\"io.element.thread\"")){ + Timber.i(" ------> Thread found") + val start = rawBody.indexOf("\"rel_type\":\"io.element.thread\"") - "\"m.relates_to\":{\"event_id\":\"-GoMTnxkfmZczOPvbjcK43WqNib3wiJVaeO_vRxwHIDA\",\"".length +1 + val end = rawBody.indexOf("\"rel_type\":\"io.element.thread\"") + "\"rel_type\":\"io.element.thread\"".length +2 + val substr = rawBody.subSequence(start,end) + val newRaw = rawBody.replaceRange(start,end,"\"m.relates_to\":{\"m.in_reply_to\":{\"event_id\":\"\$HDddlX2bJQmVS0bN5R9HDzcrGDap18b3cFDDYjTjctc\"}},") + Timber.i(" ------> ${substr}") + Timber.i(" ------> new raw $newRaw") + val newBody = newRaw.toResponseBody(contentType) + response.newBuilder().body(newBody).build() + + }else{ + val newBody = rawBody.toResponseBody(contentType) + response.newBuilder().body(newBody).build() + } + } ?: response + +// response.peekBody(Long.MAX_VALUE).string().let { networkResponse -> +// Timber.i(" ------> ThreadToReplyMapInterceptor $networkResponse") +// } + } + +// val path = request.url.encodedPath +// if(path.contains("/sync/")){ +// Timber.i("-----> SYNC REQUEST --> $responseBody") +// +// +// } + +// val body = ResponseBody.create() +// val newResponse = response.newBuilder().body(body) + return response + } + + /** + * Returns true if the request is a sync request, false otherwise + * Example of a sync request: + * https://matrix-client.matrix.org/_matrix/client/r0/sync?filter=0&set_presence=online&t... + */ + private fun isSyncRequest(request: Request): Boolean = + ApiPath.SYNC.method == request.method && request.url.encodedPath.contains(ApiPath.SYNC.path) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index 8c4af81c99..db4e07a21a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -76,6 +76,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private val cryptoService: DefaultCryptoService, private val roomMemberEventHandler: RoomMemberEventHandler, private val roomTypingUsersHandler: RoomTypingUsersHandler, + private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, @UserId private val userId: String, private val timelineInput: TimelineInput) { @@ -366,6 +367,8 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle decryptIfNeeded(event, roomId) } + threadsAwarenessHandler.handleIfNeeded(realm, roomId, event, ::decryptIfNeeded) + val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) if (event.stateKey != null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt new file mode 100644 index 0000000000..cc01f62448 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.sync.handler.room + +import io.realm.Realm +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageFormat +import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.session.room.model.tag.RoomTagContent +import org.matrix.android.sdk.api.util.JsonDict +import org.matrix.android.sdk.internal.database.mapper.EventMapper +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomTagEntity +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory +import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory +import timber.log.Timber +import javax.inject.Inject + +/** + * This handler is responsible for a smooth threads migration. It will map all incoming + * threads as replies. So a device without threads enabled/updated will be able to view + * threads response as replies to the orighinal message + */ +internal class ThreadsAwarenessHandler @Inject constructor( + private val permalinkFactory: PermalinkFactory +) { + + fun handleIfNeeded(realm: Realm, + roomId: String, + event: Event, + decryptIfNeeded: (event: Event, roomId: String) -> Unit) { + + if (!isThreadEvent(event)) return + val rootThreadEventId = getRootThreadEventId(event) ?: return + val payload = event.mxDecryptionResult?.payload?.toMutableMap() ?: return + val body = getValueFromPayload(payload, "body") ?: return + val msgType = getValueFromPayload(payload, "msgtype") ?: return + val rootThreadEventEntity = EventEntity.where(realm, eventId = rootThreadEventId).findFirst() ?: return + val rootThreadEvent = EventMapper.map(rootThreadEventEntity) + val rootThreadEventSenderId = rootThreadEvent.senderId ?: return + val rootThreadEventEventId = rootThreadEvent.eventId ?: return + + Timber.i("------> Thread event detected!") + + if (rootThreadEvent.isEncrypted()) { + decryptIfNeeded(rootThreadEvent, roomId) + } + + val rootThreadEventBody = getValueFromPayload(rootThreadEvent.mxDecryptionResult?.payload?.toMutableMap(),"body") + + val permalink = permalinkFactory.createPermalink(roomId, rootThreadEventEventId, false) + val userLink = permalinkFactory.createPermalink(rootThreadEventSenderId, false) ?: "" + + val replyFormatted = LocalEchoEventFactory.REPLY_PATTERN.format( + permalink, + userLink, + rootThreadEventSenderId, + // Remove inner mx_reply tags if any + rootThreadEventBody, + body) + + val messageTextContent = MessageTextContent( + msgType = msgType, + format = MessageFormat.FORMAT_MATRIX_HTML, + body = body, + formattedBody = replyFormatted + ).toContent() + + payload["content"] = messageTextContent + + event.mxDecryptionResult = event.mxDecryptionResult?.copy(payload = payload ) + + } + + private fun isThreadEvent(event: Event): Boolean = + event.content.toModel<MessageRelationContent>()?.relatesTo?.type == "io.element.thread" + + private fun getRootThreadEventId(event: Event): String? = + event.content.toModel<MessageRelationContent>()?.relatesTo?.eventId + + @Suppress("UNCHECKED_CAST") + private fun getValueFromPayload(payload: JsonDict?, key: String): String? { + val content = payload?.get("content") as? JsonDict + return content?.get(key) as? String + } +}