Merge branch 'develop' into feature/ons/generic_location_pin

* develop: (146 commits)
  exhaustive not needed anymore
  Invert if condition and split long line
  Use kotlin string builder
  Same issue but in the test
  Format
  Fix a crash: java.util.IllegalFormatPrecisionException https://github.com/matrix-org/element-android-rageshakes/issues/33398
  add changelog file for threads feature
  add changelog file for threads feature
  Formatting
  Improve hidden events for threads
  Add TODO for the next Weblate sync
  ktlint format
  PR remarks
  Fix a lint false positive? Anyway this was not used. Restricted API ../../../matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt:61: ListenableWorker.getTaskExecutor can only be called from within the same library group (referenced groupId=androidx.work from groupId=element-android)
  It seems that now lint rule `MissingQuantity` is an error and not a warning by default.
  Whitelist group 'org.webjars' on MavenCentral to fix lint execution
  Fix conflicts
  Formating & remove unused comments
  Fix error in unit test
  ktlint format
  ...

# Conflicts:
#	vector/src/main/java/im/vector/app/features/navigation/Navigator.kt
This commit is contained in:
Onuray Sahin 2022-02-02 14:35:30 +03:00
commit a131d28b3e
186 changed files with 5739 additions and 1252 deletions

View File

@ -180,6 +180,7 @@ jobs:
body="$(cat ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml | grep "<testsuite" | sed "s@.*tests=\(.*\)time=.*@\1@")" body="$(cat ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml | grep "<testsuite" | sed "s@.*tests=\(.*\)time=.*@\1@")"
echo "::set-output name=permalink::passed=$body" echo "::set-output name=permalink::passed=$body"
- name: Find Comment - name: Find Comment
if: github.event_name == 'pull_request'
uses: peter-evans/find-comment@v1 uses: peter-evans/find-comment@v1
id: fc id: fc
with: with:
@ -187,6 +188,7 @@ jobs:
comment-author: 'github-actions[bot]' comment-author: 'github-actions[bot]'
body-includes: Integration Tests Results body-includes: Integration Tests Results
- name: Publish results to PR - name: Publish results to PR
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v1 uses: peter-evans/create-or-update-comment@v1
with: with:
comment-id: ${{ steps.fc.outputs.comment-id }} comment-id: ${{ steps.fc.outputs.comment-id }}

View File

@ -54,7 +54,7 @@ jobs:
echo "::set-output name=body::$body" echo "::set-output name=body::$body"
fi fi
- name: Find Comment - name: Find Comment
if: always() if: always() && github.event_name == 'pull_request'
uses: peter-evans/find-comment@v1 uses: peter-evans/find-comment@v1
id: fc id: fc
with: with:
@ -62,7 +62,7 @@ jobs:
comment-author: 'github-actions[bot]' comment-author: 'github-actions[bot]'
body-includes: Ktlint Results body-includes: Ktlint Results
- name: Add comment if needed - name: Add comment if needed
if: always() && steps.ktlint-results.outputs.add_comment == 'true' if: always() && github.event_name == 'pull_request' && steps.ktlint-results.outputs.add_comment == 'true'
uses: peter-evans/create-or-update-comment@v1 uses: peter-evans/create-or-update-comment@v1
with: with:
comment-id: ${{ steps.fc.outputs.comment-id }} comment-id: ${{ steps.fc.outputs.comment-id }}
@ -73,7 +73,7 @@ jobs:
${{ steps.ktlint-results.outputs.body }} ${{ steps.ktlint-results.outputs.body }}
edit-mode: replace edit-mode: replace
- name: Delete comment if needed - name: Delete comment if needed
if: always() && steps.fc.outputs.comment-id != '' && steps.ktlint-results.outputs.add_comment == 'false' if: always() && github.event_name == 'pull_request' && steps.fc.outputs.comment-id != '' && steps.ktlint-results.outputs.add_comment == 'false'
uses: actions/github-script@v3 uses: actions/github-script@v3
with: with:
script: | script: |

View File

@ -144,6 +144,11 @@ project(":diff-match-patch") {
} }
} }
// Global configurations across all modules
ext {
isThreadingEnabled = true
}
//project(":matrix-sdk-android") { //project(":matrix-sdk-android") {
// sonarqube { // sonarqube {
// properties { // properties {

1
changelog.d/4746.feature Normal file
View File

@ -0,0 +1 @@
Initial implementation of thread messages

1
changelog.d/4873.misc Normal file
View File

@ -0,0 +1 @@
Qr code scanning fragments merged into one

1
changelog.d/5088.bugfix Normal file
View File

@ -0,0 +1 @@
Fixes call statuses in the timeline for missed/rejected calls and connected calls.

1
changelog.d/5118.misc Normal file
View File

@ -0,0 +1 @@
Fix CI/CD errors after merges for quality and integration tests

View File

@ -7,7 +7,7 @@ ext.versions = [
'targetCompat' : JavaVersion.VERSION_11, 'targetCompat' : JavaVersion.VERSION_11,
] ]
def gradle = "7.0.4" def gradle = "7.1.0"
// Ref: https://kotlinlang.org/releases.html // Ref: https://kotlinlang.org/releases.html
def kotlin = "1.5.31" def kotlin = "1.5.31"
def kotlinCoroutines = "1.5.2" def kotlinCoroutines = "1.5.2"
@ -37,7 +37,6 @@ ext.libs = [
'gradlePlugin' : "com.android.tools.build:gradle:$gradle", 'gradlePlugin' : "com.android.tools.build:gradle:$gradle",
'kotlinPlugin' : "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin", 'kotlinPlugin' : "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin",
'hiltPlugin' : "com.google.dagger:hilt-android-gradle-plugin:$dagger" 'hiltPlugin' : "com.google.dagger:hilt-android-gradle-plugin:$dagger"
], ],
jetbrains : [ jetbrains : [
'coroutinesCore' : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutines", 'coroutinesCore' : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutines",

View File

@ -175,6 +175,7 @@ ext.groups = [
'org.sonatype.oss', 'org.sonatype.oss',
'org.testng', 'org.testng',
'org.threeten', 'org.threeten',
'org.webjars',
'ru.noties', 'ru.noties',
'xerces', 'xerces',
'xml-apis', 'xml-apis',

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="-100%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:startOffset="250"
android:fromXDelta="100%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:startOffset="250"
android:fromXDelta="0" android:toXDelta="-100%p"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0" android:toXDelta="100%p"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="menu_item_ripple_size">28dp</dimen>
</resources>

View File

@ -43,6 +43,10 @@
<!-- Preview Url --> <!-- Preview Url -->
<dimen name="preview_url_view_corner_radius">8dp</dimen> <dimen name="preview_url_view_corner_radius">8dp</dimen>
<dimen name="menu_item_icon_size">24dp</dimen>
<dimen name="menu_item_size">48dp</dimen>
<dimen name="menu_item_ripple_size">48dp</dimen>
<!-- Composer --> <!-- Composer -->
<dimen name="composer_min_height">56dp</dimen> <dimen name="composer_min_height">56dp</dimen>
<dimen name="composer_attachment_size">52dp</dimen> <dimen name="composer_attachment_size">52dp</dimen>

View File

@ -32,6 +32,8 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.api.util.toOptional
typealias ThreadRootEvent = TimelineEvent
class FlowRoom(private val room: Room) { class FlowRoom(private val room: Room) {
fun liveRoomSummary(): Flow<Optional<RoomSummary>> { fun liveRoomSummary(): Flow<Optional<RoomSummary>> {
@ -98,6 +100,20 @@ class FlowRoom(private val room: Room) {
fun liveNotificationState(): Flow<RoomNotificationState> { fun liveNotificationState(): Flow<RoomNotificationState> {
return room.getLiveRoomNotificationState().asFlow() return room.getLiveRoomNotificationState().asFlow()
} }
fun liveThreadList(): Flow<List<ThreadRootEvent>> {
return room.getAllThreadsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreads()
}
}
fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> {
return room.getMarkedThreadNotificationsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getMarkedThreadNotifications()
}
}
} }
fun Room.flow(): FlowRoom { fun Room.flow(): FlowRoom {

View File

@ -38,6 +38,8 @@ android {
resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\"" resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\""
resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\"" resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\""
// Indicates whether or not threading support is enabled
buildConfigField "Boolean", "THREADING_ENABLED", "${isThreadingEnabled}"
defaultConfig { defaultConfig {
consumerProguardFiles 'proguard-rules.pro' consumerProguardFiles 'proguard-rules.pro'
} }
@ -62,8 +64,8 @@ android {
} }
} }
adbOptions { installation {
installOptions "-g" installOptions '-g'
// timeOutInMs 350 * 1000 // timeOutInMs 350 * 1000
} }
@ -139,6 +141,9 @@ dependencies {
kapt 'dk.ilios:realmfieldnameshelper:2.0.0' kapt 'dk.ilios:realmfieldnameshelper:2.0.0'
// Shared Preferences
implementation libs.androidx.preferenceKtx
// Work // Work
implementation libs.androidx.work implementation libs.androidx.work

View File

@ -157,15 +157,21 @@ class CommonTestHelper(context: Context) {
/** /**
* Will send nb of messages provided by count parameter but waits every 10 messages to avoid gap in sync * Will send nb of messages provided by count parameter but waits every 10 messages to avoid gap in sync
*/ */
private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long): List<TimelineEvent> { private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long, rootThreadEventId: String? = null): List<TimelineEvent> {
val sentEvents = ArrayList<TimelineEvent>(count) val sentEvents = ArrayList<TimelineEvent>(count)
(1 until count + 1) (1 until count + 1)
.map { "$message #$it" } .map { "$message #$it" }
.chunked(10) .chunked(10)
.forEach { batchedMessages -> .forEach { batchedMessages ->
batchedMessages.forEach { formattedMessage -> batchedMessages.forEach { formattedMessage ->
if (rootThreadEventId != null) {
room.replyInThread(
rootThreadEventId = rootThreadEventId,
replyInThreadText = formattedMessage)
} else {
room.sendTextMessage(formattedMessage) room.sendTextMessage(formattedMessage)
} }
}
waitWithLatch(timeout) { latch -> waitWithLatch(timeout) { latch ->
val timelineListener = object : Timeline.Listener { val timelineListener = object : Timeline.Listener {
@ -196,6 +202,27 @@ class CommonTestHelper(context: Context) {
return sentEvents return sentEvents
} }
/**
* Reply in a thread
* @param room the room where to send the messages
* @param message the message to send
* @param numberOfMessages the number of time the message will be sent
*/
fun replyInThreadMessage(
room: Room,
message: String,
numberOfMessages: Int,
rootThreadEventId: String,
timeout: Long = TestConstants.timeOutMillis): List<TimelineEvent> {
val timeline = room.createTimeline(null, TimelineSettings(10))
timeline.start()
val sentEvents = sendTextMessagesBatched(timeline, room, message, numberOfMessages, timeout, rootThreadEventId)
timeline.dispose()
// Check that all events has been created
assertEquals("Message number do not match $sentEvents", numberOfMessages.toLong(), sentEvents.size.toLong())
return sentEvents
}
// PRIVATE METHODS ***************************************************************************** // PRIVATE METHODS *****************************************************************************
/** /**

View File

@ -0,0 +1,339 @@
/*
* Copyright 2022 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.session.room.threads
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeNull
import org.amshove.kluent.shouldBeTrue
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.junit.runners.MethodSorters
import org.matrix.android.sdk.InstrumentedTest
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.events.model.isTextMessage
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.common.CommonTestHelper
import org.matrix.android.sdk.common.CryptoTestHelper
import java.util.concurrent.CountDownLatch
@RunWith(JUnit4::class)
@FixMethodOrder(MethodSorters.JVM)
class ThreadMessagingTest : InstrumentedTest {
@Test
fun reply_in_thread_should_create_a_thread() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send a message in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 1)
val initMessage = sentMessages.first()
initMessage.root.isThread().shouldBeFalse()
initMessage.root.isTextMessage().shouldBeTrue()
initMessage.root.getRootThreadEventId().shouldBeNull()
initMessage.root.threadDetails?.isRootThread?.shouldBeFalse()
// Let's reply in timeline to that message
val repliesInThread = commonTestHelper.replyInThreadMessage(
room = aliceRoom,
message = "Reply In the above thread",
numberOfMessages = 1,
rootThreadEventId = initMessage.root.eventId.orEmpty())
val replyInThread = repliesInThread.first()
replyInThread.root.isThread().shouldBeTrue()
replyInThread.root.isTextMessage().shouldBeTrue()
replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId)
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull {
it.root.eventId == initMessage.root.eventId
}?.root?.threadDetails
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
initMessageThreadDetails?.numberOfThreads?.shouldBe(1)
true
}
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
}
@Test
fun reply_in_thread_should_create_a_thread_from_other_user() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send a message in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 1)
val initMessage = sentMessages.first()
initMessage.root.isThread().shouldBeFalse()
initMessage.root.isTextMessage().shouldBeTrue()
initMessage.root.getRootThreadEventId().shouldBeNull()
initMessage.root.threadDetails?.isRootThread?.shouldBeFalse()
// Let's reply in timeline to that message from another user
val bobSession = cryptoTestData.secondSession!!
val bobRoomId = cryptoTestData.roomId
val bobRoom = bobSession.getRoom(bobRoomId)!!
val repliesInThread = commonTestHelper.replyInThreadMessage(
room = bobRoom,
message = "Reply In the above thread",
numberOfMessages = 1,
rootThreadEventId = initMessage.root.eventId.orEmpty())
val replyInThread = repliesInThread.first()
replyInThread.root.isThread().shouldBeTrue()
replyInThread.root.isTextMessage().shouldBeTrue()
replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId)
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
initMessageThreadDetails?.numberOfThreads?.shouldBe(1)
true
}
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
bobSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
initMessageThreadDetails?.numberOfThreads?.shouldBe(1)
true
}
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
bobSession.stopSync()
}
@Test
fun reply_in_thread_to_timeline_message_multiple_times() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send 5 messages in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 5)
sentMessages.forEach {
it.root.isThread().shouldBeFalse()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId().shouldBeNull()
it.root.threadDetails?.isRootThread?.shouldBeFalse()
}
// let's start the thread from the second message
val selectedInitMessage = sentMessages[1]
// Let's reply 40 times in the timeline to the second message
val repliesInThread = commonTestHelper.replyInThreadMessage(
room = aliceRoom,
message = "Reply In the above thread",
numberOfMessages = 40,
rootThreadEventId = selectedInitMessage.root.eventId.orEmpty())
repliesInThread.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(selectedInitMessage.root.eventId.orEmpty()) ?: assert(false)
}
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == selectedInitMessage.root.eventId }?.root?.threadDetails
// Selected init message should be the thread root
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
// All threads should be 40
initMessageThreadDetails?.numberOfThreads?.shouldBeEqualTo(40)
true
}
// Because we sent more than 30 messages we should paginate a bit more
timeline.paginate(Timeline.Direction.BACKWARDS, 50)
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
}
@Test
fun thread_summary_advanced_validation_after_multiple_messages_in_multiple_threads() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send 5 messages in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 5)
sentMessages.forEach {
it.root.isThread().shouldBeFalse()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId().shouldBeNull()
it.root.threadDetails?.isRootThread?.shouldBeFalse()
}
// let's start the thread from the second message
val firstMessage = sentMessages[0]
val secondMessage = sentMessages[1]
// Alice will reply in thread to the second message 35 times
val aliceThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage(
room = aliceRoom,
message = "Alice reply In the above second thread message",
numberOfMessages = 35,
rootThreadEventId = secondMessage.root.eventId.orEmpty())
// Let's reply in timeline to that message from another user
val bobSession = cryptoTestData.secondSession!!
val bobRoomId = cryptoTestData.roomId
val bobRoom = bobSession.getRoom(bobRoomId)!!
// Bob will reply in thread to the first message 35 times
val bobThreadRepliesInFirstMessage = commonTestHelper.replyInThreadMessage(
room = bobRoom,
message = "Bob reply In the above first thread message",
numberOfMessages = 42,
rootThreadEventId = firstMessage.root.eventId.orEmpty())
// Bob will also reply in second thread 5 times
val bobThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage(
room = bobRoom,
message = "Another Bob reply In the above second thread message",
numberOfMessages = 20,
rootThreadEventId = secondMessage.root.eventId.orEmpty())
aliceThreadRepliesInSecondMessage.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false)
}
bobThreadRepliesInFirstMessage.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(firstMessage.root.eventId.orEmpty()) ?: assert(false)
}
bobThreadRepliesInSecondMessage.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false)
}
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val firstMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == firstMessage.root.eventId }?.root?.threadDetails
val secondMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == secondMessage.root.eventId }?.root?.threadDetails
// first & second message should be the thread root
firstMessageThreadDetails?.isRootThread?.shouldBeTrue()
secondMessageThreadDetails?.isRootThread?.shouldBeTrue()
// First thread message should contain 42
firstMessageThreadDetails?.numberOfThreads shouldBeEqualTo 42
// Second thread message should contain 35+20
secondMessageThreadDetails?.numberOfThreads shouldBeEqualTo 55
true
}
// Because we sent more than 30 messages we should paginate a bit more
timeline.paginate(Timeline.Direction.BACKWARDS, 50)
timeline.paginate(Timeline.Direction.BACKWARDS, 50)
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
}
}

View File

@ -25,9 +25,14 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.util.ContentUtils
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
@ -98,6 +103,9 @@ data class Event(
@Transient @Transient
var sendStateDetails: String? = null var sendStateDetails: String? = null
@Transient
var threadDetails: ThreadDetails? = null
fun sendStateError(): MatrixError? { fun sendStateError(): MatrixError? {
return sendStateDetails?.let { return sendStateDetails?.let {
val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java) val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
@ -123,6 +131,7 @@ data class Event(
it.mCryptoErrorReason = mCryptoErrorReason it.mCryptoErrorReason = mCryptoErrorReason
it.sendState = sendState it.sendState = sendState
it.ageLocalTs = ageLocalTs it.ageLocalTs = ageLocalTs
it.threadDetails = threadDetails
} }
} }
@ -185,6 +194,51 @@ data class Event(
return contentMap?.let { JSONObject(adapter.toJson(it)).toString(4) } return contentMap?.let { JSONObject(adapter.toJson(it)).toString(4) }
} }
/**
* Returns a user friendly content depending on the message type.
* It can be used especially for message summaries.
* It will return a decrypted text message or an empty string otherwise.
*/
fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted"
val text = getDecryptedValue() ?: return null
return when {
isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
isFileMessage() -> "sent a file."
isAudioMessage() -> "sent an audio file."
isImageMessage() -> "sent an image."
isVideoMessage() -> "sent a video."
isSticker() -> "sent a sticker"
isPoll() -> getPollQuestion() ?: "created a poll."
else -> text
}
}
private fun Event.isQuote(): Boolean {
if (isReplyRenderedInThread()) return false
return getDecryptedValue("formatted_body")?.contains("<blockquote>") ?: false
}
/**
* Determines whether or not current event has mentioned the user
*/
fun isUserMentioned(userId: String): Boolean {
return getDecryptedValue("formatted_body")?.contains(userId) ?: false
}
/**
* Decrypt the message, or return the pure payload value if there is no encryption
*/
private fun getDecryptedValue(key: String = "body"): String? {
return if (isEncrypted()) {
@Suppress("UNCHECKED_CAST")
val decryptedContent = mxDecryptionResult?.payload?.get("content") as? JsonDict
decryptedContent?.get(key) as? String
} else {
content?.get(key) as? String
}
}
/** /**
* Tells if the event is redacted * Tells if the event is redacted
*/ */
@ -217,7 +271,7 @@ data class Event(
if (mCryptoError != other.mCryptoError) return false if (mCryptoError != other.mCryptoError) return false
if (mCryptoErrorReason != other.mCryptoErrorReason) return false if (mCryptoErrorReason != other.mCryptoErrorReason) return false
if (sendState != other.sendState) return false if (sendState != other.sendState) return false
if (threadDetails != other.threadDetails) return false
return true return true
} }
@ -236,6 +290,8 @@ data class Event(
result = 31 * result + (mCryptoError?.hashCode() ?: 0) result = 31 * result + (mCryptoError?.hashCode() ?: 0)
result = 31 * result + (mCryptoErrorReason?.hashCode() ?: 0) result = 31 * result + (mCryptoErrorReason?.hashCode() ?: 0)
result = 31 * result + sendState.hashCode() result = 31 * result + sendState.hashCode()
result = 31 * result + threadDetails.hashCode()
return result return result
} }
} }
@ -293,20 +349,51 @@ fun Event.isAttachmentMessage(): Boolean {
} }
} }
fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END
fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
fun Event.getRelationContent(): RelationDefaultContent? { fun Event.getRelationContent(): RelationDefaultContent? {
return if (isEncrypted()) { return if (isEncrypted()) {
content.toModel<EncryptedEventContent>()?.relatesTo content.toModel<EncryptedEventContent>()?.relatesTo
} else { } else {
content.toModel<MessageContent>()?.relatesTo content.toModel<MessageContent>()?.relatesTo ?: run {
// Special case to handle stickers, while there is only a local msgtype for stickers
if (getClearType() == EventType.STICKER) {
getClearContent().toModel<MessageStickerContent>()?.relatesTo
} else {
null
}
}
} }
} }
/**
* Returns the poll question or null otherwise
*/
fun Event.getPollQuestion(): String? =
getPollContent()?.pollCreationInfo?.question?.question
/**
* Returns the relation content for a specific type or null otherwise
*/
fun Event.getRelationContentForType(type: String): RelationDefaultContent? =
getRelationContent()?.takeIf { it.type == type }
fun Event.isReply(): Boolean { fun Event.isReply(): Boolean {
return getRelationContent()?.inReplyTo?.eventId != null return getRelationContent()?.inReplyTo?.eventId != null
} }
fun Event.isReplyRenderedInThread(): Boolean {
return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true
}
fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null
fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_THREAD)?.eventId
fun Event.isEdition(): Boolean { fun Event.isEdition(): Boolean {
return getRelationContent()?.takeIf { it.type == RelationType.REPLACE }?.eventId != null return getRelationContentForType(RelationType.REPLACE)?.eventId != null
} }
fun Event.getPresenceContent(): PresenceContent? { fun Event.getPresenceContent(): PresenceContent? {
@ -315,3 +402,7 @@ fun Event.getPresenceContent(): PresenceContent? {
fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
content?.toModel<RoomMemberContent>()?.membership == Membership.INVITE content?.toModel<RoomMemberContent>()?.membership == Membership.INVITE
fun Event.getPollContent(): MessagePollContent? {
return content.toModel<MessagePollContent>()
}

View File

@ -28,9 +28,9 @@ object RelationType {
/** Lets you define an event which references an existing event.*/ /** Lets you define an event which references an existing event.*/
const val REFERENCE = "m.reference" const val REFERENCE = "m.reference"
/** Lets you define an thread event that belongs to another existing event.*/ /** Lets you define an event which is a thread reply to an existing event.*/
// const val THREAD = "m.thread" // m.thread is not yet released in the backend const val THREAD = "m.thread"
const val THREAD = "io.element.thread" // io.element.thread will be replaced by m.thread when it is released const val IO_THREAD = "io.element.thread"
/** Lets you define an event which adds a response to an existing event.*/ /** Lets you define an event which adds a response to an existing event.*/
const val RESPONSE = "org.matrix.response" const val RESPONSE = "org.matrix.response"

View File

@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.send.DraftService
import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService import org.matrix.android.sdk.api.session.room.tags.TagsService
import org.matrix.android.sdk.api.session.room.threads.ThreadsService
import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.util.Optional
*/ */
interface Room : interface Room :
TimelineService, TimelineService,
ThreadsService,
SendService, SendService,
DraftService, DraftService,
ReadService, ReadService,

View File

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.relation
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.PollType import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
@ -45,6 +46,9 @@ import org.matrix.android.sdk.api.util.Optional
* m.reference - lets you define an event which references an existing event. * m.reference - lets you define an event which references an existing event.
* When aggregated, currently doesn't do anything special, but in future could bundle chains of references (i.e. threads). * When aggregated, currently doesn't do anything special, but in future could bundle chains of references (i.e. threads).
* These are primarily intended for handling replies (and in future threads). * These are primarily intended for handling replies (and in future threads).
*
* m.thread - lets you define an event which is a thread reply to an existing event.
* When aggregated, returns the most thread event
*/ */
interface RelationService { interface RelationService {
@ -118,10 +122,15 @@ interface RelationService {
* @param eventReplied the event referenced by the reply * @param eventReplied the event referenced by the reply
* @param replyText the reply text * @param replyText the reply text
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @param showInThread If true, relation will be added to the reply in order to be visible from within threads
* @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation
*/ */
fun replyToMessage(eventReplied: TimelineEvent, fun replyToMessage(eventReplied: TimelineEvent,
replyText: CharSequence, replyText: CharSequence,
autoMarkdown: Boolean = false): Cancelable? autoMarkdown: Boolean = false,
showInThread: Boolean = false,
rootThreadEventId: String? = null
): Cancelable?
/** /**
* Get the current EventAnnotationsSummary * Get the current EventAnnotationsSummary
@ -136,4 +145,31 @@ interface RelationService {
* @return the LiveData of EventAnnotationsSummary * @return the LiveData of EventAnnotationsSummary
*/ */
fun getEventAnnotationsSummaryLive(eventId: String): LiveData<Optional<EventAnnotationsSummary>> fun getEventAnnotationsSummaryLive(eventId: String): LiveData<Optional<EventAnnotationsSummary>>
/**
* Creates a thread reply for an existing timeline event
* The replyInThreadText can be a Spannable and contains special spans (MatrixItemSpan) that will be translated
* by the sdk into pills.
* @param rootThreadEventId the root thread eventId
* @param replyInThreadText the reply text
* @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
* @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @param eventReplied the event referenced by the reply within a thread
*/
fun replyInThread(rootThreadEventId: String,
replyInThreadText: CharSequence,
msgType: String = MessageType.MSGTYPE_TEXT,
autoMarkdown: Boolean = false,
formattedText: String? = null,
eventReplied: TimelineEvent? = null): Cancelable?
/**
* Get all the thread replies for the specified rootThreadEventId
* The return list will contain the original root thread event and all the thread replies to that event
* Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready
* from the backend
* @param rootThreadEventId the root thread eventId
*/
suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean
} }

View File

@ -21,5 +21,8 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ReplyToContent( data class ReplyToContent(
@Json(name = "event_id") val eventId: String? = null @Json(name = "event_id") val eventId: String? = null,
@Json(name = "render_in") val renderIn: List<String>? = null
) )
fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true

View File

@ -64,7 +64,7 @@ interface SendService {
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @return a [Cancelable] * @return a [Cancelable]
*/ */
fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable
/** /**
* Method to send a media asynchronously. * Method to send a media asynchronously.
@ -72,11 +72,13 @@ interface SendService {
* @param compressBeforeSending set to true to compress images before sending them * @param compressBeforeSending set to true to compress images before sending them
* @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
* It can be useful to send media to multiple room. It's safe to include the current roomId in this set * It can be useful to send media to multiple room. It's safe to include the current roomId in this set
* @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread
* @return a [Cancelable] * @return a [Cancelable]
*/ */
fun sendMedia(attachment: ContentAttachmentData, fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable roomIds: Set<String>,
rootThreadEventId: String? = null): Cancelable
/** /**
* Method to send a list of media asynchronously. * Method to send a list of media asynchronously.
@ -84,11 +86,13 @@ interface SendService {
* @param compressBeforeSending set to true to compress images before sending them * @param compressBeforeSending set to true to compress images before sending them
* @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
* It can be useful to send media to multiple room. It's safe to include the current roomId in this set * It can be useful to send media to multiple room. It's safe to include the current roomId in this set
* @param rootThreadEventId when this param is not null, all the Media will be sent in this specific thread
* @return a [Cancelable] * @return a [Cancelable]
*/ */
fun sendMedias(attachments: List<ContentAttachmentData>, fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable roomIds: Set<String>,
rootThreadEventId: String? = null): Cancelable
/** /**
* Send a poll to the room. * Send a poll to the room.

View File

@ -0,0 +1,67 @@
/*
* Copyright 2022 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.api.session.room.threads
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* This interface defines methods to interact with threads related features.
* It's implemented at the room level within the main timeline.
*/
interface ThreadsService {
/**
* Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level
*/
fun getAllThreadsLive(): LiveData<List<TimelineEvent>>
/**
* Returns a list of all the thread root TimelineEvents that exists at the room level
*/
fun getAllThreads(): List<TimelineEvent>
/**
* Returns a [LiveData] list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>>
/**
* Returns a list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotifications(): List<TimelineEvent>
/**
* Returns whether or not the current user is participating in the thread
* @param rootThreadEventId the eventId of the current thread
*/
fun isUserParticipatingInThread(rootThreadEventId: String): Boolean
/**
* Enhance the provided root thread TimelineEvent [List] by adding the latest
* message edition for that thread
* @return the enhanced [List] with edited updates
*/
fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent>
/**
* Marks the current thread as read in local DB.
* note: read receipts within threads are not yet supported with the API
* @param rootThreadEventId the root eventId of the current thread
*/
suspend fun markThreadAsRead(rootThreadEventId: String)
}

View File

@ -43,7 +43,7 @@ interface Timeline {
/** /**
* This must be called before any other method after creating the timeline. It ensures the underlying database is open * This must be called before any other method after creating the timeline. It ensures the underlying database is open
*/ */
fun start() fun start(rootThreadEventId: String? = null)
/** /**
* This must be called when you don't need the timeline. It ensures the underlying database get closed. * This must be called when you don't need the timeline. It ensures the underlying database get closed.

View File

@ -22,7 +22,9 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.isEdition import org.matrix.android.sdk.api.session.events.model.isEdition
import org.matrix.android.sdk.api.session.events.model.isPoll
import org.matrix.android.sdk.api.session.events.model.isReply import org.matrix.android.sdk.api.session.events.model.isReply
import org.matrix.android.sdk.api.session.events.model.isSticker
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.api.session.room.model.ReadReceipt
@ -149,6 +151,13 @@ fun TimelineEvent.isEdition(): Boolean {
return root.isEdition() return root.isEdition()
} }
fun TimelineEvent.isPoll(): Boolean =
root.isPoll()
fun TimelineEvent.isSticker(): Boolean {
return root.isSticker()
}
/** /**
* Get the latest message body, after a possible edition, stripping the reply prefix if necessary * Get the latest message body, after a possible edition, stripping the reply prefix if necessary
*/ */

View File

@ -27,5 +27,14 @@ data class TimelineSettings(
/** /**
* If true, will build read receipts for each event. * If true, will build read receipts for each event.
*/ */
val buildReadReceipts: Boolean = true val buildReadReceipts: Boolean = true,
) /**
* The root thread eventId if this is a thread timeline, or null if this is NOT a thread timeline
*/
val rootThreadEventId: String? = null) {
/**
* Returns true if this is a thread timeline or false otherwise
*/
fun isThreadTimeline() = rootThreadEventId != null
}

View File

@ -0,0 +1,34 @@
/*
* 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.api.session.threads
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
/**
* This class contains all the details needed for threads.
* Is is mainly used from within an Event.
*/
data class ThreadDetails(
val isRootThread: Boolean = false,
val numberOfThreads: Int = 0,
val threadSummarySenderInfo: SenderInfo? = null,
val threadSummaryLatestTextMessage: String? = null,
val lastMessageTimestamp: Long? = null,
var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE,
val isThread: Boolean = false,
val lastRootThreadEdition: String? = null
)

View File

@ -0,0 +1,25 @@
/*
* 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.api.session.threads
/**
* This class defines the state of a thread notification badge
*/
data class ThreadNotificationBadgeState(
val numberOfLocalUnreadThreads: Int = 0,
val isUserMentioned: Boolean = false
)

View File

@ -0,0 +1,33 @@
/*
* 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.api.session.threads
/**
* This class defines the state of a thread notification
*/
enum class ThreadNotificationState {
// There are no new message
NO_NEW_MESSAGE,
// There is at least one new message
NEW_MESSAGE,
// The is at least one new message that should be highlighted
// ex. "Hello @aris.kotsomitopoulos"
NEW_HIGHLIGHTED_MESSAGE;
}

View File

@ -0,0 +1,28 @@
/*
* 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.api.session.threads
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* This class contains a thread TimelineEvent along with a boolean that
* determines if the current user has participated in that event
*/
data class ThreadTimelineEvent(
val timelineEvent: TimelineEvent,
val isParticipating: Boolean
)

View File

@ -35,7 +35,7 @@ internal class MXOutboundSessionInfo(
val sessionLifetime = System.currentTimeMillis() - creationTime val sessionLifetime = System.currentTimeMillis() - creationTime
if (useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { if (useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) {
Timber.v("## needsRotation() : Rotating megolm session after " + useCount + ", " + sessionLifetime + "ms") Timber.v("## needsRotation() : Rotating megolm session after $useCount, ${sessionLifetime}ms")
needsRotation = true needsRotation = true
} }

View File

@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent
import org.matrix.android.sdk.api.session.room.model.VersioningState import org.matrix.android.sdk.api.session.room.model.VersioningState
import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.crypto.model.event.EncryptionEventContent import org.matrix.android.sdk.internal.crypto.model.event.EncryptionEventContent
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields
@ -56,7 +57,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
) : RealmMigration { ) : RealmMigration {
companion object { companion object {
const val SESSION_STORE_SCHEMA_VERSION = 21L const val SESSION_STORE_SCHEMA_VERSION = 22L
} }
/** /**
@ -90,6 +91,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion <= 18) migrateTo19(realm) if (oldVersion <= 18) migrateTo19(realm)
if (oldVersion <= 19) migrateTo20(realm) if (oldVersion <= 19) migrateTo20(realm)
if (oldVersion <= 20) migrateTo21(realm) if (oldVersion <= 20) migrateTo21(realm)
if (oldVersion <= 21) migrateTo22(realm)
} }
private fun migrateTo1(realm: DynamicRealm) { private fun migrateTo1(realm: DynamicRealm) {
@ -445,4 +447,19 @@ internal class RealmSessionStoreMigration @Inject constructor(
} }
} }
} }
private fun migrateTo22(realm: DynamicRealm) {
Timber.d("Step 21 -> 22")
val eventEntity = realm.schema.get("TimelineEventEntity") ?: return
realm.schema.get("EventEntity")
?.addField(EventEntityFields.IS_ROOT_THREAD, Boolean::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.NUMBER_OF_THREADS, Int::class.java)
?.addField(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, String::class.java)
?.transform {
it.setString(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NO_NEW_MESSAGE.name)
}
?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity)
}
} }

View File

@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.find import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.database.query.whereRoomId
import org.matrix.android.sdk.internal.extensions.assertIsManaged import org.matrix.android.sdk.internal.extensions.assertIsManaged
import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
import timber.log.Timber import timber.log.Timber
@ -81,7 +82,7 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity,
internal fun ChunkEntity.addTimelineEvent(roomId: String, internal fun ChunkEntity.addTimelineEvent(roomId: String,
eventEntity: EventEntity, eventEntity: EventEntity,
direction: PaginationDirection, direction: PaginationDirection,
roomMemberContentsByUser: Map<String, RoomMemberContent?>) { roomMemberContentsByUser: Map<String, RoomMemberContent?>? = null) {
val eventId = eventEntity.eventId val eventId = eventEntity.eventId
if (timelineEvents.find(eventId) != null) { if (timelineEvents.find(eventId) != null) {
return return
@ -101,7 +102,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String,
?.also { it.cleanUp(eventEntity.sender) } ?.also { it.cleanUp(eventEntity.sender) }
this.readReceipts = readReceiptsSummaryEntity this.readReceipts = readReceiptsSummaryEntity
this.displayIndex = displayIndex this.displayIndex = displayIndex
val roomMemberContent = roomMemberContentsByUser[senderId] val roomMemberContent = roomMemberContentsByUser?.get(senderId)
this.senderAvatar = roomMemberContent?.avatarUrl this.senderAvatar = roomMemberContent?.avatarUrl
this.senderName = roomMemberContent?.displayName this.senderName = roomMemberContent?.displayName
isUniqueDisplayName = if (roomMemberContent?.displayName != null) { isUniqueDisplayName = if (roomMemberContent?.displayName != null) {
@ -157,9 +158,21 @@ private fun ChunkEntity.addTimelineEventFromMerge(realm: Realm, timelineEventEnt
this.senderName = timelineEventEntity.senderName this.senderName = timelineEventEntity.senderName
this.isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName this.isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName
} }
handleThreadSummary(realm, eventId, copied)
timelineEvents.add(copied) timelineEvents.add(copied)
} }
/**
* Upon copy of the timeline events we should update the latestMessage TimelineEventEntity with the new one
*/
private fun handleThreadSummary(realm: Realm, oldEventId: String, newTimelineEventEntity: TimelineEventEntity) {
EventEntity
.whereRoomId(realm, newTimelineEventEntity.roomId)
.equalTo(EventEntityFields.IS_ROOT_THREAD, true)
.equalTo(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.EVENT_ID, oldEventId)
.findFirst()?.threadSummaryLatestMessage = newTimelineEventEntity
}
private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventEntity, senderId: String): ReadReceiptsSummaryEntity { private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventEntity, senderId: String): ReadReceiptsSummaryEntity {
val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventEntity.eventId).findFirst() val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventEntity.eventId).findFirst()
?: realm.createObject<ReadReceiptsSummaryEntity>(eventEntity.eventId).apply { ?: realm.createObject<ReadReceiptsSummaryEntity>(eventEntity.eventId).apply {

View File

@ -0,0 +1,321 @@
/*
* 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.database.helper
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.Sort
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.findIncludingEvent
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.database.query.whereRoomId
private typealias ThreadSummary = Pair<Int, TimelineEventEntity>?
/**
* Finds the root thread event and update it with the latest message summary along with the number
* of threads included. If there is no root thread event no action is done
*/
internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded(
roomId: String,
realm: Realm, currentUserId: String,
chunkEntity: ChunkEntity? = null,
shouldUpdateNotifications: Boolean = true) {
for ((rootThreadEventId, eventEntity) in this) {
eventEntity.threadSummaryInThread(eventEntity.realm, rootThreadEventId, chunkEntity)?.let { threadSummary ->
val numberOfMessages = threadSummary.first
val latestEventInThread = threadSummary.second
// If this is a thread message, find its root event if exists
val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity
rootThreadEvent?.markEventAsRoot(
threadsCounted = numberOfMessages,
latestMessageTimelineEventEntity = latestEventInThread
)
}
}
if (shouldUpdateNotifications) {
updateNotificationsNew(roomId, realm, currentUserId)
}
}
/**
* Finds the root event of the the current thread event message.
* Returns the EventEntity or null if the root event do not exist
*/
internal fun EventEntity.findRootThreadEvent(): EventEntity? =
rootThreadEventId?.let {
EventEntity
.where(realm, it)
.findFirst()
}
/**
* Mark or update the current event a root thread event
*/
internal fun EventEntity.markEventAsRoot(
threadsCounted: Int,
latestMessageTimelineEventEntity: TimelineEventEntity?) {
isRootThread = true
numberOfThreads = threadsCounted
threadSummaryLatestMessage = latestMessageTimelineEventEntity
}
/**
* Count the number of threads for the provided root thread eventId, and finds the latest event message
* @param rootThreadEventId The root eventId that will find the number of threads
* @return A ThreadSummary containing the counted threads and the latest event message
*/
internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary {
// Number of messages
val messages = TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.count()
.toInt()
if (messages <= 0) return null
// Find latest thread event, we know it exists
var chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) ?: chunkEntity ?: return null
var result: TimelineEventEntity? = null
// Iterate the chunk until we find our latest event
while (result == null) {
result = findLatestSortedChunkEvent(chunk, rootThreadEventId)
chunk = ChunkEntity.find(realm, roomId, nextToken = chunk.prevToken) ?: break
}
if (result == null && chunkEntity != null) {
// Find latest event from our current chunk
result = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId)
} else if (result != null && chunkEntity != null) {
val currentChunkLatestEvent = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId)
result = findMostRecentEvent(result, currentChunkLatestEvent)
}
result ?: return null
return ThreadSummary(messages, result)
}
/**
* Lets compare them in case user is moving forward in the timeline and we cannot know the
* exact chunk sequence while currentChunk is not yet committed in the DB
*/
private fun findMostRecentEvent(result: TimelineEventEntity, currentChunkLatestEvent: TimelineEventEntity?): TimelineEventEntity {
currentChunkLatestEvent ?: return result
val currentChunkEventTimestamp = currentChunkLatestEvent.root?.originServerTs ?: return result
val resultTimestamp = result.root?.originServerTs ?: return result
if (currentChunkEventTimestamp > resultTimestamp) {
return currentChunkLatestEvent
}
return result
}
/**
* Find the latest event of the current chunk
*/
private fun findLatestSortedChunkEvent(chunk: ChunkEntity, rootThreadEventId: String): TimelineEventEntity? =
chunk.timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)?.firstOrNull {
it.root?.rootThreadEventId == rootThreadEventId
}
/**
* Find all TimelineEventEntity that are root threads for the specified room
* @param roomId The room that all stored root threads will be returned
*/
internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery<TimelineEventEntity> =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
.sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING)
/**
* Map each root thread TimelineEvent with the equivalent decrypted text edition/replacement
*/
internal fun List<TimelineEvent>.mapEventsWithEdition(realm: Realm, roomId: String): List<TimelineEvent> =
this.map {
EventAnnotationsSummaryEntity
.where(realm, roomId, eventId = it.eventId)
.findFirst()
?.editSummary
?.editions
?.lastOrNull()
?.eventId
?.let { editedEventId ->
TimelineEventEntity.where(realm, roomId, eventId = editedEventId).findFirst()?.let { editedEvent ->
it.root.threadDetails = it.root.threadDetails?.copy(lastRootThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary()
?: "(edited)")
it
} ?: it
} ?: it
}
/**
* Returns a list of all the marked unread threads that exists for the specified room
* @param roomId The roomId that the user is currently in
*/
internal fun TimelineEventEntity.Companion.findAllLocalThreadNotificationsForRoomId(realm: Realm, roomId: String): RealmQuery<TimelineEventEntity> =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
.beginGroup()
.equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_MESSAGE.name)
.or()
.equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE.name)
.endGroup()
/**
* Returns whether or not the given user is participating in a current thread
* @param roomId the room that the thread exists
* @param rootThreadEventId the thread that the search will be done
* @param senderId the user that will try to find participation
*/
internal fun TimelineEventEntity.Companion.isUserParticipatingInThread(realm: Realm, roomId: String, rootThreadEventId: String, senderId: String): Boolean =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.equalTo(TimelineEventEntityFields.ROOT.SENDER, senderId)
.findFirst()
?.let { true }
?: false
/**
* Returns whether or not the given user is mentioned in a current thread
* @param roomId the room that the thread exists
* @param rootThreadEventId the thread that the search will be done
* @param userId the user that will try to find if there is a mention
*/
internal fun TimelineEventEntity.Companion.isUserMentionedInThread(realm: Realm, roomId: String, rootThreadEventId: String, userId: String): Boolean =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.equalTo(TimelineEventEntityFields.ROOT.SENDER, userId)
.findAll()
.firstOrNull { isUserMentioned(userId, it) }
?.let { true }
?: false
/**
* Find the read receipt for the current user
*/
internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String): String? =
ReadReceiptEntity.where(realm, roomId = roomId, userId = userId)
.findFirst()
?.eventId
/**
* Returns whether or not the user is mentioned in the event
*/
internal fun isUserMentioned(currentUserId: String, timelineEventEntity: TimelineEventEntity?): Boolean {
return timelineEventEntity?.root?.asDomain()?.isUserMentioned(currentUserId) == true
}
/**
* Update badge notifications. Count the number of new thread events after the latest
* read receipt and aggregate. This function will find and notify new thread events
* that the user is either mentioned, or the user had participated in.
* Important: If the root thread event is not fetched notification will not work
* Important: It will work only with the latest chunk, while read marker will be changed
* immediately so we should not display wrong notifications
*/
internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId: String) {
val readReceipt = findMyReadReceipt(realm, roomId, currentUserId) ?: return
val readReceiptChunk = ChunkEntity
.findIncludingEvent(realm, readReceipt) ?: return
val readReceiptChunkTimelineEvents = readReceiptChunk
.timelineEvents
.where()
.equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
.findAll() ?: return
val readReceiptChunkPosition = readReceiptChunkTimelineEvents.indexOfFirst { it.eventId == readReceipt }
if (readReceiptChunkPosition == -1) return
if (readReceiptChunkPosition < readReceiptChunkTimelineEvents.lastIndex) {
// If the read receipt is found inside the chunk
val threadEventsAfterReadReceipt = readReceiptChunkTimelineEvents
.slice(readReceiptChunkPosition..readReceiptChunkTimelineEvents.lastIndex)
.filter { it.root?.isThread() == true }
// In order for the below code to work for old events, we should save the previous read receipt
// and then continue with the chunk search for that read receipt
/*
val newThreadEventsList = arrayListOf<TimelineEventEntity>()
newThreadEventsList.addAll(threadEventsAfterReadReceipt)
// got from latest chunk all new threads, lets move to the others
var nextChunk = ChunkEntity
.find(realm = realm, roomId = roomId, nextToken = readReceiptChunk.nextToken)
.takeIf { readReceiptChunk.nextToken != null }
while (nextChunk != null) {
newThreadEventsList.addAll(nextChunk.timelineEvents
.filter { it.root?.isThread() == true })
nextChunk = ChunkEntity
.find(realm = realm, roomId = roomId, nextToken = nextChunk.nextToken)
.takeIf { readReceiptChunk.nextToken != null }
}*/
// Find if the user is mentioned in those events
val userMentionsList = threadEventsAfterReadReceipt
.filter {
isUserMentioned(currentUserId = currentUserId, it)
}.map {
it.root?.rootThreadEventId
}
// Find the root events in the new thread events
val rootThreads = threadEventsAfterReadReceipt.distinctBy { it.root?.rootThreadEventId }.mapNotNull { it.root?.rootThreadEventId }
// Update root thread events only if the user have participated in
rootThreads.forEach { eventId ->
val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
realm = realm,
roomId = roomId,
rootThreadEventId = eventId,
senderId = currentUserId)
val rootThreadEventEntity = EventEntity.where(realm, eventId).findFirst()
if (isUserParticipating) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
}
if (userMentionsList.contains(eventId)) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
}
}
}
}

View File

@ -0,0 +1,47 @@
/*
* Copyright 2022 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.database.lightweight
import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import javax.inject.Inject
/**
* The purpose of this class is to provide an alternative and lightweight way to store settings/data
* on the sdi without using the database. This should be used just for sdk/user preferences and
* not for large data sets
*/
class LightweightSettingsStorage @Inject constructor(context: Context) {
private val sdkDefaultPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
fun setThreadMessagesEnabled(enabled: Boolean) {
sdkDefaultPrefs.edit {
putBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, enabled)
}
}
fun areThreadMessagesEnabled(): Boolean {
return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, false)
}
companion object {
const val MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED = "MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED"
}
}

View File

@ -21,7 +21,11 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.UnsignedData import org.matrix.android.sdk.api.session.events.model.UnsignedData
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.di.MoshiProvider
@ -51,6 +55,10 @@ internal object EventMapper {
} }
eventEntity.decryptionErrorReason = event.mCryptoErrorReason eventEntity.decryptionErrorReason = event.mCryptoErrorReason
eventEntity.decryptionErrorCode = event.mCryptoError?.name eventEntity.decryptionErrorCode = event.mCryptoError?.name
eventEntity.isRootThread = event.threadDetails?.isRootThread ?: false
eventEntity.rootThreadEventId = event.getRootThreadEventId()
eventEntity.numberOfThreads = event.threadDetails?.numberOfThreads ?: 0
eventEntity.threadNotificationState = event.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE
return eventEntity return eventEntity
} }
@ -93,6 +101,23 @@ internal object EventMapper {
MXCryptoError.ErrorType.valueOf(errorCode) MXCryptoError.ErrorType.valueOf(errorCode)
} }
it.mCryptoErrorReason = eventEntity.decryptionErrorReason it.mCryptoErrorReason = eventEntity.decryptionErrorReason
it.threadDetails = ThreadDetails(
isRootThread = eventEntity.isRootThread,
isThread = if (it.threadDetails?.isThread == true) true else eventEntity.isThread(),
numberOfThreads = eventEntity.numberOfThreads,
threadSummarySenderInfo = eventEntity.threadSummaryLatestMessage?.let { timelineEventEntity ->
SenderInfo(
userId = timelineEventEntity.root?.sender ?: "",
displayName = timelineEventEntity.senderName,
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
avatarUrl = timelineEventEntity.senderAvatar
)
},
threadNotificationState = eventEntity.threadNotificationState,
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(),
lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs
)
} }
} }
} }
@ -101,9 +126,15 @@ internal fun EventEntity.asDomain(castJsonNumbers: Boolean = false): Event {
return EventMapper.map(this, castJsonNumbers) return EventMapper.map(this, castJsonNumbers)
} }
internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?): EventEntity { internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?, contentToInject: String? = null): EventEntity {
return EventMapper.map(this, roomId).apply { return EventMapper.map(this, roomId).apply {
this.sendState = sendState this.sendState = sendState
this.ageLocalTs = ageLocalTs this.ageLocalTs = ageLocalTs
contentToInject?.let {
this.content = it
if (this.type == EventType.STICKER) {
this.type = EventType.MESSAGE
}
}
} }
} }

View File

@ -19,7 +19,7 @@ package org.matrix.android.sdk.internal.database.model
import io.realm.RealmObject import io.realm.RealmObject
import io.realm.annotations.Index import io.realm.annotations.Index
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.di.MoshiProvider
@ -40,7 +40,12 @@ internal open class EventEntity(@Index var eventId: String = "",
var unsignedData: String? = null, var unsignedData: String? = null,
var redacts: String? = null, var redacts: String? = null,
var decryptionResultJson: String? = null, var decryptionResultJson: String? = null,
var ageLocalTs: Long? = null var ageLocalTs: Long? = null,
// Thread related, no need to create a new Entity for performance
@Index var isRootThread: Boolean = false,
@Index var rootThreadEventId: String? = null,
var numberOfThreads: Int = 0,
var threadSummaryLatestMessage: TimelineEventEntity? = null
) : RealmObject() { ) : RealmObject() {
private var sendStateStr: String = SendState.UNKNOWN.name private var sendStateStr: String = SendState.UNKNOWN.name
@ -53,6 +58,15 @@ internal open class EventEntity(@Index var eventId: String = "",
sendStateStr = value.name sendStateStr = value.name
} }
private var threadNotificationStateStr: String = ThreadNotificationState.NO_NEW_MESSAGE.name
var threadNotificationState: ThreadNotificationState
get() {
return ThreadNotificationState.valueOf(threadNotificationStateStr)
}
set(value) {
threadNotificationStateStr = value.name
}
var decryptionErrorCode: String? = null var decryptionErrorCode: String? = null
set(value) { set(value) {
if (value != field) field = value if (value != field) field = value
@ -65,10 +79,10 @@ internal open class EventEntity(@Index var eventId: String = "",
companion object companion object
fun setDecryptionResult(result: MXEventDecryptionResult, clearEvent: JsonDict? = null) { fun setDecryptionResult(result: MXEventDecryptionResult) {
assertIsManaged() assertIsManaged()
val decryptionResult = OlmDecryptionResult( val decryptionResult = OlmDecryptionResult(
payload = clearEvent ?: result.clearEvent, payload = result.clearEvent,
senderKey = result.senderCurve25519Key, senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
@ -84,4 +98,6 @@ internal open class EventEntity(@Index var eventId: String = "",
.findFirst() .findFirst()
?.canBeProcessed = true ?.canBeProcessed = true
} }
fun isThread(): Boolean = rootThreadEventId != null
} }

View File

@ -49,6 +49,11 @@ internal fun EventEntity.Companion.where(realm: Realm, eventId: String): RealmQu
.equalTo(EventEntityFields.EVENT_ID, eventId) .equalTo(EventEntityFields.EVENT_ID, eventId)
} }
internal fun EventEntity.Companion.whereRoomId(realm: Realm, roomId: String): RealmQuery<EventEntity> {
return realm.where<EventEntity>()
.equalTo(EventEntityFields.ROOM_ID, roomId)
}
internal fun EventEntity.Companion.where(realm: Realm, eventIds: List<String>): RealmQuery<EventEntity> { internal fun EventEntity.Companion.where(realm: Realm, eventIds: List<String>): RealmQuery<EventEntity> {
return realm.where<EventEntity>() return realm.where<EventEntity>()
.`in`(EventEntityFields.EVENT_ID, eventIds.toTypedArray()) .`in`(EventEntityFields.EVENT_ID, eventIds.toTypedArray())
@ -85,3 +90,8 @@ internal fun RealmList<EventEntity>.find(eventId: String): EventEntity? {
internal fun RealmList<EventEntity>.fastContains(eventId: String): Boolean { internal fun RealmList<EventEntity>.fastContains(eventId: String): Boolean {
return this.find(eventId) != null return this.find(eventId) != null
} }
internal fun EventEntity.Companion.whereRootThreadEventId(realm: Realm, rootThreadEventId: String): RealmQuery<EventEntity> {
return realm.where<EventEntity>()
.equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
}

View File

@ -59,6 +59,7 @@ internal fun TimelineEventEntity.Companion.latestEvent(realm: Realm,
filters: TimelineEventFilters = TimelineEventFilters()): TimelineEventEntity? { filters: TimelineEventFilters = TimelineEventFilters()): TimelineEventEntity? {
val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: return null val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: return null
val sendingTimelineEvents = roomEntity.sendingTimelineEvents.where().filterEvents(filters) val sendingTimelineEvents = roomEntity.sendingTimelineEvents.where().filterEvents(filters)
val liveEvents = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)?.timelineEvents?.where()?.filterEvents(filters) val liveEvents = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)?.timelineEvents?.where()?.filterEvents(filters)
val query = if (includesSending && sendingTimelineEvents.findAll().isNotEmpty()) { val query = if (includesSending && sendingTimelineEvents.findAll().isNotEmpty()) {
sendingTimelineEvents sendingTimelineEvents
@ -100,6 +101,7 @@ internal fun RealmQuery<TimelineEventEntity>.filterEvents(filters: TimelineEvent
if (filters.filterRedacted) { if (filters.filterRedacted) {
not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED) not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED)
} }
return this return this
} }

View File

@ -66,7 +66,7 @@ internal class ThumbnailExtractor @Inject constructor(
thumbnail.recycle() thumbnail.recycle()
outputStream.reset() outputStream.reset()
} ?: run { } ?: run {
Timber.e("Cannot extract video thumbnail at %s", attachment.queryUri.toString()) Timber.e("Cannot extract video thumbnail at ${attachment.queryUri}")
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Cannot extract video thumbnail") Timber.e(e, "Cannot extract video thumbnail")

View File

@ -48,6 +48,16 @@ data class RoomEventFilter(
* a wildcard to match any sequence of characters. * a wildcard to match any sequence of characters.
*/ */
@Json(name = "types") val types: List<String>? = null, @Json(name = "types") val types: List<String>? = null,
/**
* A list of relation types which must be exist pointing to the event being filtered.
* If this list is absent then no filtering is done on relation types.
*/
@Json(name = "relation_types") val relationTypes: List<String>? = null,
/**
* A list of senders of relations which must exist pointing to the event being filtered.
* If this list is absent then no filtering is done on relation types.
*/
@Json(name = "relation_senders") val relationSenders: List<String>? = null,
/** /**
* A list of room IDs to include. If this list is absent then all rooms are included. * A list of room IDs to include. If this list is absent then all rooms are included.
*/ */

View File

@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.session.room.send.DraftService
import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService import org.matrix.android.sdk.api.session.room.tags.TagsService
import org.matrix.android.sdk.api.session.room.threads.ThreadsService
import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@ -54,6 +55,7 @@ import java.security.InvalidParameterException
internal class DefaultRoom(override val roomId: String, internal class DefaultRoom(override val roomId: String,
private val roomSummaryDataSource: RoomSummaryDataSource, private val roomSummaryDataSource: RoomSummaryDataSource,
private val timelineService: TimelineService, private val timelineService: TimelineService,
private val threadsService: ThreadsService,
private val sendService: SendService, private val sendService: SendService,
private val draftService: DraftService, private val draftService: DraftService,
private val stateService: StateService, private val stateService: StateService,
@ -77,6 +79,7 @@ internal class DefaultRoom(override val roomId: String,
) : ) :
Room, Room,
TimelineService by timelineService, TimelineService by timelineService,
ThreadsService by threadsService,
SendService by sendService, SendService by sendService,
DraftService by draftService, DraftService by draftService,
StateService by stateService, StateService by stateService,

View File

@ -44,6 +44,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.SessionManager
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.crypto.verification.toState import org.matrix.android.sdk.internal.crypto.verification.toState
import org.matrix.android.sdk.internal.database.helper.findRootThreadEvent
import org.matrix.android.sdk.internal.database.mapper.ContentMapper import org.matrix.android.sdk.internal.database.mapper.ContentMapper
import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.mapper.EventMapper
import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity
@ -332,6 +333,29 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
) )
} }
} }
if (!isLocalEcho) {
val replaceEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst()
handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions)
}
}
/**
* Check if the edition is on the latest thread event, and update it accordingly
*/
private fun handleThreadSummaryEdition(editedEvent: EventEntity?,
replaceEvent: TimelineEventEntity?,
editions: List<EditionOfEvent>?) {
replaceEvent ?: return
editedEvent ?: return
editedEvent.findRootThreadEvent()?.apply {
val threadSummaryEventId = threadSummaryLatestMessage?.eventId
if (editedEvent.eventId == threadSummaryEventId || editions?.any { it.eventId == threadSummaryEventId } == true) {
// The edition is for the latest event or for any event replaced, this is to handle multiple
// edits of the same latest event
threadSummaryLatestMessage = replaceEvent
}
}
} }
private fun handleResponse(realm: Realm, private fun handleResponse(realm: Realm,

View File

@ -226,7 +226,8 @@ internal interface RoomAPI {
suspend fun getRelations(@Path("roomId") roomId: String, suspend fun getRelations(@Path("roomId") roomId: String,
@Path("eventId") eventId: String, @Path("eventId") eventId: String,
@Path("relationType") relationType: String, @Path("relationType") relationType: String,
@Path("eventType") eventType: String @Path("eventType") eventType: String,
@Query("limit") limit: Int? = null
): RelationsResponse ): RelationsResponse
/** /**

View File

@ -35,6 +35,7 @@ import org.matrix.android.sdk.internal.session.room.state.DefaultStateService
import org.matrix.android.sdk.internal.session.room.state.SendStateTask import org.matrix.android.sdk.internal.session.room.state.SendStateTask
import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource
import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService
import org.matrix.android.sdk.internal.session.room.threads.DefaultThreadsService
import org.matrix.android.sdk.internal.session.room.timeline.DefaultTimelineService import org.matrix.android.sdk.internal.session.room.timeline.DefaultTimelineService
import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService
import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService
@ -50,6 +51,7 @@ internal interface RoomFactory {
internal class DefaultRoomFactory @Inject constructor(private val cryptoService: CryptoService, internal class DefaultRoomFactory @Inject constructor(private val cryptoService: CryptoService,
private val roomSummaryDataSource: RoomSummaryDataSource, private val roomSummaryDataSource: RoomSummaryDataSource,
private val timelineServiceFactory: DefaultTimelineService.Factory, private val timelineServiceFactory: DefaultTimelineService.Factory,
private val threadsServiceFactory: DefaultThreadsService.Factory,
private val sendServiceFactory: DefaultSendService.Factory, private val sendServiceFactory: DefaultSendService.Factory,
private val draftServiceFactory: DefaultDraftService.Factory, private val draftServiceFactory: DefaultDraftService.Factory,
private val stateServiceFactory: DefaultStateService.Factory, private val stateServiceFactory: DefaultStateService.Factory,
@ -76,6 +78,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService:
roomId = roomId, roomId = roomId,
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
timelineService = timelineServiceFactory.create(roomId), timelineService = timelineServiceFactory.create(roomId),
threadsService = threadsServiceFactory.create(roomId),
sendService = sendServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId),
draftService = draftServiceFactory.create(roomId), draftService = draftServiceFactory.create(roomId),
stateService = stateServiceFactory.create(roomId), stateService = stateServiceFactory.create(roomId),

View File

@ -77,6 +77,8 @@ import org.matrix.android.sdk.internal.session.room.relation.DefaultUpdateQuickR
import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask
import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask
import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask
import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask
import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask
import org.matrix.android.sdk.internal.session.room.state.DefaultSendStateTask import org.matrix.android.sdk.internal.session.room.state.DefaultSendStateTask
@ -289,4 +291,7 @@ internal abstract class RoomModule {
@Binds @Binds
abstract fun bindGetRoomSummaryTask(task: DefaultGetRoomSummaryTask): GetRoomSummaryTask abstract fun bindGetRoomSummaryTask(task: DefaultGetRoomSummaryTask): GetRoomSummaryTask
@Binds
abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask
} }

View File

@ -83,7 +83,9 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
// } // }
val modified = unsignedData.copy(redactedEvent = redactionEvent) val modified = unsignedData.copy(redactedEvent = redactionEvent)
eventToPrune.content = ContentMapper.map(emptyMap()) // I Commented the line below, it should not be empty while we lose all the previous info about
// the redacted event
// eventToPrune.content = ContentMapper.map(emptyMap())
eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified) eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified)
eventToPrune.decryptionResultJson = null eventToPrune.decryptionResultJson = null
eventToPrune.decryptionErrorCode = null eventToPrune.decryptionErrorCode = null

View File

@ -32,12 +32,15 @@ import org.matrix.android.sdk.api.util.NoOpCancellable
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider
import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.task.TaskExecutor import org.matrix.android.sdk.internal.task.TaskExecutor
@ -51,9 +54,12 @@ internal class DefaultRelationService @AssistedInject constructor(
private val eventSenderProcessor: EventSenderProcessor, private val eventSenderProcessor: EventSenderProcessor,
private val eventFactory: LocalEchoEventFactory, private val eventFactory: LocalEchoEventFactory,
private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, private val cryptoSessionInfoProvider: CryptoSessionInfoProvider,
private val cryptoService: DefaultCryptoService,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val fetchEditHistoryTask: FetchEditHistoryTask, private val fetchEditHistoryTask: FetchEditHistoryTask,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val timelineEventMapper: TimelineEventMapper, private val timelineEventMapper: TimelineEventMapper,
@UserId private val userId: String,
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
private val taskExecutor: TaskExecutor) : private val taskExecutor: TaskExecutor) :
RelationService { RelationService {
@ -139,8 +145,20 @@ internal class DefaultRelationService @AssistedInject constructor(
return fetchEditHistoryTask.execute(FetchEditHistoryTask.Params(roomId, eventId)) return fetchEditHistoryTask.execute(FetchEditHistoryTask.Params(roomId, eventId))
} }
override fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Cancelable? { override fun replyToMessage(
val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown) eventReplied: TimelineEvent,
replyText: CharSequence,
autoMarkdown: Boolean,
showInThread: Boolean,
rootThreadEventId: String?
): Cancelable? {
val event = eventFactory.createReplyTextEvent(
roomId = roomId,
eventReplied = eventReplied,
replyText = replyText,
autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId,
showInThread = showInThread)
?.also { saveLocalEcho(it) } ?.also { saveLocalEcho(it) }
?: return null ?: return null
@ -166,6 +184,47 @@ internal class DefaultRelationService @AssistedInject constructor(
} }
} }
override fun replyInThread(
rootThreadEventId: String,
replyInThreadText: CharSequence,
msgType: String,
autoMarkdown: Boolean,
formattedText: String?,
eventReplied: TimelineEvent?): Cancelable? {
val event = if (eventReplied != null) {
// Reply within a thread
eventFactory.createReplyTextEvent(
roomId = roomId,
eventReplied = eventReplied,
replyText = replyInThreadText,
autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId,
showInThread = false
)
?.also {
saveLocalEcho(it)
}
?: return null
} else {
// Normal thread reply
eventFactory.createThreadTextEvent(
rootThreadEventId = rootThreadEventId,
roomId = roomId,
text = replyInThreadText,
msgType = msgType,
autoMarkdown = autoMarkdown,
formattedText = formattedText)
.also {
saveLocalEcho(it)
}
}
return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
}
override suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean {
return fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(roomId, rootThreadEventId))
}
/** /**
* Saves the event in database as a local echo. * 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. * SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room.

View File

@ -97,7 +97,13 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor:
val roomId = replyToEdit.roomId val roomId = replyToEdit.roomId
if (replyToEdit.root.sendState.hasFailed()) { if (replyToEdit.root.sendState.hasFailed()) {
// We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event.
val editedEvent = eventFactory.createReplyTextEvent(roomId, originalTimelineEvent, newBodyText, false)?.copy( val editedEvent = eventFactory.createReplyTextEvent(
roomId = roomId,
eventReplied = originalTimelineEvent,
replyText = newBodyText,
autoMarkdown = false,
showInThread = false
)?.copy(
eventId = replyToEdit.eventId eventId = replyToEdit.eventId
) ?: return NoOpCancellable ) ?: return NoOpCancellable
updateFailedEchoWithEvent(roomId, replyToEdit.eventId, editedEvent) updateFailedEchoWithEvent(roomId, replyToEdit.eventId, editedEvent)

View File

@ -0,0 +1,207 @@
/*
* 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.room.relation.threads
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider
import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
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.ReactionAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent
import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import timber.log.Timber
import javax.inject.Inject
internal interface FetchThreadTimelineTask : Task<FetchThreadTimelineTask.Params, Boolean> {
data class Params(
val roomId: String,
val rootThreadEventId: String
)
}
internal class DefaultFetchThreadTimelineTask @Inject constructor(
private val roomAPI: RoomAPI,
private val globalErrorReceiver: GlobalErrorReceiver,
private val cryptoSessionInfoProvider: CryptoSessionInfoProvider,
@SessionDatabase private val monarchy: Monarchy,
@UserId private val userId: String,
private val cryptoService: DefaultCryptoService
) : FetchThreadTimelineTask {
override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean {
val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId)
val response = executeRequest(globalErrorReceiver) {
roomAPI.getRelations(
roomId = params.roomId,
eventId = params.rootThreadEventId,
relationType = RelationType.IO_THREAD,
eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE,
limit = 2000
)
}
val threadList = response.chunks + listOfNotNull(response.originalEvent)
return storeNewEventsIfNeeded(threadList, params.roomId)
}
/**
* Store new events if they are not already received, and returns weather or not,
* a timeline update should be made
* @param threadList is the list containing the thread replies
* @param roomId the roomId of the the thread
* @return
*/
private suspend fun storeNewEventsIfNeeded(threadList: List<Event>, roomId: String): Boolean {
var eventsSkipped = 0
monarchy
.awaitTransaction { realm ->
val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
for (event in threadList.reversed()) {
if (event.eventId == null || event.senderId == null || event.type == null) {
eventsSkipped++
continue
}
if (EventEntity.where(realm, event.eventId).findFirst() != null) {
// Skip if event already exists
eventsSkipped++
continue
}
if (event.isEncrypted()) {
// Decrypt events that will be stored
decryptIfNeeded(event, roomId)
}
handleReaction(realm, event, roomId)
val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC)
// Sender info
roomMemberContentsByUser.getOrPut(event.senderId) {
// If we don't have any new state on this user, get it from db
val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root
rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
}
chunk?.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
eventEntity.rootThreadEventId?.let {
// This is a thread event
optimizedThreadSummaryMap[it] = eventEntity
} ?: run {
// This is a normal event or a root thread one
optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
}
}
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(
roomId = roomId,
realm = realm,
currentUserId = userId,
shouldUpdateNotifications = false
)
}
Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}")
return eventsSkipped == threadList.size
}
/**
* Invoke the event decryption mechanism for a specific event
*/
private fun decryptIfNeeded(event: Event, roomId: String) {
try {
// Event from sync does not have roomId, so add it to the event first
val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "")
event.mxDecryptionResult = OlmDecryptionResult(
payload = result.clearEvent,
senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
)
} catch (e: MXCryptoError) {
if (e is MXCryptoError.Base) {
event.mCryptoError = e.errorType
event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription
}
}
}
private fun handleReaction(realm: Realm,
event: Event,
roomId: String) {
val unsignedData = event.unsignedData ?: return
val relatedEventId = event.eventId ?: return
unsignedData.relations?.annotations?.chunk?.forEach { relationChunk ->
if (relationChunk.type == EventType.REACTION) {
val reaction = relationChunk.key
Timber.i("----> Annotation found in ${event.eventId} ${relationChunk.key} ")
val eventSummary = EventAnnotationsSummaryEntity.getOrCreate(realm, roomId, relatedEventId)
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
Timber.v("Adding synced reaction $reaction")
sum.count = 1
// reactionEventId not included in the /relations API
// sum.sourceEvents.add(reactionEventId)
eventSummary.reactionsSummary.add(sum)
} else {
sum.count += 1
}
}
}
}
}

View File

@ -98,8 +98,14 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) } .let { sendEvent(it) }
} }
override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable { override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable {
return localEchoEventFactory.createQuotedTextEvent(roomId, quotedEvent, text, autoMarkdown) return localEchoEventFactory.createQuotedTextEvent(
roomId = roomId,
quotedEvent = quotedEvent,
text = text,
autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId
)
.also { createLocalEcho(it) } .also { createLocalEcho(it) }
.let { sendEvent(it) } .let { sendEvent(it) }
} }
@ -254,22 +260,37 @@ internal class DefaultSendService @AssistedInject constructor(
override fun sendMedias(attachments: List<ContentAttachmentData>, override fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable { roomIds: Set<String>,
rootThreadEventId: String?
): Cancelable {
return attachments.mapTo(CancelableBag()) { return attachments.mapTo(CancelableBag()) {
sendMedia(it, compressBeforeSending, roomIds) sendMedia(
attachment = it,
compressBeforeSending = compressBeforeSending,
roomIds = roomIds,
rootThreadEventId = rootThreadEventId)
} }
} }
override fun sendMedia(attachment: ContentAttachmentData, override fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable { roomIds: Set<String>,
rootThreadEventId: String?
): Cancelable {
// Ensure that the event will not be send in a thread if we are a different flow.
// Like sending files to multiple rooms
val rootThreadId = if (roomIds.isNotEmpty()) null else rootThreadEventId
// Create an event with the media file path // Create an event with the media file path
// Ensure current roomId is included in the set // Ensure current roomId is included in the set
val allRoomIds = (roomIds + roomId).toList() val allRoomIds = (roomIds + roomId).toList()
// Create local echo for each room // Create local echo for each room
val allLocalEchoes = allRoomIds.map { val allLocalEchoes = allRoomIds.map {
localEchoEventFactory.createMediaEvent(it, attachment).also { event -> localEchoEventFactory.createMediaEvent(
roomId = it,
attachment = attachment,
rootThreadEventId = rootThreadId).also { event ->
createLocalEcho(event) createLocalEcho(event)
} }
} }

View File

@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.UnsignedData import org.matrix.android.sdk.api.session.events.model.UnsignedData
import org.matrix.android.sdk.api.session.events.model.toContent 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.AudioInfo import org.matrix.android.sdk.api.session.room.model.message.AudioInfo
import org.matrix.android.sdk.api.session.room.model.message.AudioWaveformInfo import org.matrix.android.sdk.api.session.room.model.message.AudioWaveformInfo
import org.matrix.android.sdk.api.session.room.model.message.FileInfo import org.matrix.android.sdk.api.session.room.model.message.FileInfo
@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
@ -292,13 +294,16 @@ internal class LocalEchoEventFactory @Inject constructor(
)) ))
} }
fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event { fun createMediaEvent(roomId: String,
attachment: ContentAttachmentData,
rootThreadEventId: String?
): Event {
return when (attachment.type) { return when (attachment.type) {
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment) ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId)
ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment) ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId)
ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false) ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId)
ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true) ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true, rootThreadEventId = rootThreadEventId)
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment) ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId)
} }
} }
@ -321,7 +326,7 @@ internal class LocalEchoEventFactory @Inject constructor(
unsignedData = UnsignedData(age = null, transactionId = localId)) unsignedData = UnsignedData(age = null, transactionId = localId))
} }
private fun createImageEvent(roomId: String, attachment: ContentAttachmentData): Event { private fun createImageEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
var width = attachment.width var width = attachment.width
var height = attachment.height var height = attachment.height
@ -345,12 +350,19 @@ internal class LocalEchoEventFactory @Inject constructor(
height = height?.toInt() ?: 0, height = height?.toInt() ?: 0,
size = attachment.size size = attachment.size
), ),
url = attachment.queryUri.toString() url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
type = RelationType.IO_THREAD,
eventId = it,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event { private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
val mediaDataRetriever = MediaMetadataRetriever() val mediaDataRetriever = MediaMetadataRetriever()
mediaDataRetriever.setDataSource(context, attachment.queryUri) mediaDataRetriever.setDataSource(context, attachment.queryUri)
@ -381,12 +393,23 @@ internal class LocalEchoEventFactory @Inject constructor(
thumbnailUrl = attachment.queryUri.toString(), thumbnailUrl = attachment.queryUri.toString(),
thumbnailInfo = thumbnailInfo thumbnailInfo = thumbnailInfo
), ),
url = attachment.queryUri.toString() url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
type = RelationType.IO_THREAD,
eventId = it,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
private fun createAudioEvent(roomId: String, attachment: ContentAttachmentData, isVoiceMessage: Boolean): Event { private fun createAudioEvent(roomId: String,
attachment: ContentAttachmentData,
isVoiceMessage: Boolean,
rootThreadEventId: String?
): Event {
val content = MessageAudioContent( val content = MessageAudioContent(
msgType = MessageType.MSGTYPE_AUDIO, msgType = MessageType.MSGTYPE_AUDIO,
body = attachment.name ?: "audio", body = attachment.name ?: "audio",
@ -400,12 +423,19 @@ internal class LocalEchoEventFactory @Inject constructor(
duration = attachment.duration?.toInt(), duration = attachment.duration?.toInt(),
waveform = waveformSanitizer.sanitize(attachment.waveform) waveform = waveformSanitizer.sanitize(attachment.waveform)
), ),
voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap() voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
type = RelationType.IO_THREAD,
eventId = it,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
private fun createFileEvent(roomId: String, attachment: ContentAttachmentData): Event { private fun createFileEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
val content = MessageFileContent( val content = MessageFileContent(
msgType = MessageType.MSGTYPE_FILE, msgType = MessageType.MSGTYPE_FILE,
body = attachment.name ?: "file", body = attachment.name ?: "file",
@ -413,7 +443,14 @@ internal class LocalEchoEventFactory @Inject constructor(
mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() }, mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() },
size = attachment.size size = attachment.size
), ),
url = attachment.queryUri.toString() url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
type = RelationType.IO_THREAD,
eventId = it,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
@ -423,6 +460,7 @@ internal class LocalEchoEventFactory @Inject constructor(
} }
fun createEvent(roomId: String, type: String, content: Content?): Event { fun createEvent(roomId: String, type: String, content: Content?): Event {
val newContent = enhanceStickerIfNeeded(type, content) ?: content
val localId = LocalEcho.createLocalEchoId() val localId = LocalEcho.createLocalEchoId()
return Event( return Event(
roomId = roomId, roomId = roomId,
@ -430,19 +468,65 @@ internal class LocalEchoEventFactory @Inject constructor(
senderId = userId, senderId = userId,
eventId = localId, eventId = localId,
type = type, type = type,
content = content, content = newContent,
unsignedData = UnsignedData(age = null, transactionId = localId) unsignedData = UnsignedData(age = null, transactionId = localId)
) )
} }
/**
* Enhance sticker to support threads fallback if needed
*/
private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? {
var newContent: Content? = null
if (type == EventType.STICKER) {
val isThread = (content.toModel<MessageStickerContent>())?.relatesTo?.type == RelationType.IO_THREAD
val rootThreadEventId = (content.toModel<MessageStickerContent>())?.relatesTo?.eventId
if (isThread && rootThreadEventId != null) {
val newRelationalDefaultContent = (content.toModel<MessageStickerContent>())?.relatesTo?.copy(
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId))
)
newContent = (content.toModel<MessageStickerContent>())?.copy(
relatesTo = newRelationalDefaultContent
).toContent()
}
}
return newContent
}
/**
* Creates a thread event related to the already existing root event
*/
fun createThreadTextEvent(
rootThreadEventId: String,
roomId: String,
text: CharSequence,
msgType: String,
autoMarkdown: Boolean,
formattedText: String?): Event {
val content = formattedText?.let { TextContent(text.toString(), it) } ?: createTextContent(text, autoMarkdown)
return createEvent(
roomId,
EventType.MESSAGE,
content.toThreadTextContent(
rootThreadEventId = rootThreadEventId,
latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId),
msgType = msgType)
.toContent())
}
private fun dummyOriginServerTs(): Long { private fun dummyOriginServerTs(): Long {
return System.currentTimeMillis() return System.currentTimeMillis()
} }
/**
* Creates a reply to a regular timeline Event or a thread Event if needed
*/
fun createReplyTextEvent(roomId: String, fun createReplyTextEvent(roomId: String,
eventReplied: TimelineEvent, eventReplied: TimelineEvent,
replyText: CharSequence, replyText: CharSequence,
autoMarkdown: Boolean): Event? { autoMarkdown: Boolean,
rootThreadEventId: String? = null,
showInThread: Boolean): Event? {
// Fallbacks and event representation // Fallbacks and event representation
// TODO Add error/warning logs when any of this is null // TODO Add error/warning logs when any of this is null
val permalink = permalinkFactory.createPermalink(eventReplied.root, false) ?: return null val permalink = permalinkFactory.createPermalink(eventReplied.root, false) ?: return null
@ -473,11 +557,33 @@ internal class LocalEchoEventFactory @Inject constructor(
format = MessageFormat.FORMAT_MATRIX_HTML, format = MessageFormat.FORMAT_MATRIX_HTML,
body = replyFallback, body = replyFallback,
formattedBody = replyFormatted, formattedBody = replyFormatted,
relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId)) relatesTo = generateReplyRelationContent(
) eventId = eventId,
rootThreadEventId = rootThreadEventId,
showAsReply = showInThread))
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
/**
* Generates the appropriate relatesTo object for a reply event.
* It can either be a regular reply or a reply within a thread
* "m.relates_to": {
* "rel_type": "m.thread",
* "event_id": "$thread_root",
* "m.in_reply_to": {
* "event_id": "$event_target",
* "render_in": ["m.thread"]
* }
* }
*/
private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showAsReply: Boolean): RelationDefaultContent =
rootThreadEventId?.let {
RelationDefaultContent(
type = RelationType.IO_THREAD,
eventId = it,
inReplyTo = ReplyToContent(eventId = eventId, renderIn = if (showAsReply) arrayListOf("m.thread") else null))
} ?: RelationDefaultContent(null, null, ReplyToContent(eventId = eventId))
private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String { private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String {
return REPLY_PATTERN.format( return REPLY_PATTERN.format(
permalink, permalink,
@ -488,6 +594,7 @@ internal class LocalEchoEventFactory @Inject constructor(
newBodyFormatted newBodyFormatted
) )
} }
private fun buildReplyFallback(body: TextContent, originalSenderId: String?, newBodyText: String): String { private fun buildReplyFallback(body: TextContent, originalSenderId: String?, newBodyText: String): String {
return buildString { return buildString {
append("> <") append("> <")
@ -593,11 +700,28 @@ internal class LocalEchoEventFactory @Inject constructor(
quotedEvent: TimelineEvent, quotedEvent: TimelineEvent,
text: String, text: String,
autoMarkdown: Boolean, autoMarkdown: Boolean,
rootThreadEventId: String?
): Event { ): Event {
val messageContent = quotedEvent.getLastMessageContent() val messageContent = quotedEvent.getLastMessageContent()
val textMsg = messageContent?.body val textMsg = messageContent?.body
val quoteText = legacyRiotQuoteText(textMsg, text) val quoteText = legacyRiotQuoteText(textMsg, text)
return createFormattedTextEvent(roomId, markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), MessageType.MSGTYPE_TEXT)
return if (rootThreadEventId != null) {
createMessageEvent(
roomId,
markdownParser
.parse(quoteText, force = true, advanced = autoMarkdown)
.toThreadTextContent(
rootThreadEventId = rootThreadEventId,
latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId),
msgType = MessageType.MSGTYPE_TEXT)
)
} else {
createFormattedTextEvent(
roomId,
markdownParser.parse(quoteText, force = true, advanced = autoMarkdown),
MessageType.MSGTYPE_TEXT)
}
} }
private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
@ -631,6 +755,7 @@ internal class LocalEchoEventFactory @Inject constructor(
// </mx-reply> // </mx-reply>
// No whitespace because currently breaks temporary formatted text to Span // No whitespace because currently breaks temporary formatted text to Span
const val REPLY_PATTERN = """<mx-reply><blockquote><a href="%s">In reply to</a> <a href="%s">%s</a><br />%s</blockquote></mx-reply>%s""" const val REPLY_PATTERN = """<mx-reply><blockquote><a href="%s">In reply to</a> <a href="%s">%s</a><br />%s</blockquote></mx-reply>%s"""
const val QUOTE_PATTERN = """<blockquote><p>%s</p></blockquote><p>%s</p>"""
// This is used to replace inner mx-reply tags // This is used to replace inner mx-reply tags
val MX_REPLY_REGEX = "<mx-reply>.*</mx-reply>".toRegex() val MX_REPLY_REGEX = "<mx-reply>.*</mx-reply>".toRegex()

View File

@ -215,4 +215,13 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
} }
} }
} }
/**
* Returns the latest known thread event message, or the rootThreadEventId if no other event found
*/
fun getLatestThreadEvent(rootThreadEventId: String): String {
return realmSessionProvider.withRealm { realm ->
EventEntity.where(realm, eventId = rootThreadEventId).findFirst()?.threadSummaryLatestMessage?.eventId
} ?: rootThreadEventId
}
} }

View File

@ -16,9 +16,12 @@
package org.matrix.android.sdk.internal.session.room.send package org.matrix.android.sdk.internal.session.room.send
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromReply import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromReply
@ -41,6 +44,29 @@ fun TextContent.toMessageTextContent(msgType: String = MessageType.MSGTYPE_TEXT)
) )
} }
/**
* Transform a TextContent to a thread message content. It will also add the inReplyTo
* latestThreadEventId in order for the clients without threads enabled to render it appropriately
* If latest event not found, we pass rootThreadEventId
*/
fun TextContent.toThreadTextContent(
rootThreadEventId: String,
latestThreadEventId: String,
msgType: String = MessageType.MSGTYPE_TEXT): MessageTextContent {
return MessageTextContent(
msgType = msgType,
format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null },
body = text,
relatesTo = RelationDefaultContent(
type = RelationType.IO_THREAD,
eventId = rootThreadEventId,
inReplyTo = ReplyToContent(
eventId = latestThreadEventId
)),
formattedBody = formattedText
)
}
fun TextContent.removeInReplyFallbacks(): TextContent { fun TextContent.removeInReplyFallbacks(): TextContent {
return copy( return copy(
text = extractUsefulTextFromReply(this.text), text = extractUsefulTextFromReply(this.text),

View File

@ -0,0 +1,103 @@
/*
* Copyright 2022 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.room.threads
import androidx.lifecycle.LiveData
import com.zhuinden.monarchy.Monarchy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.realm.Realm
import org.matrix.android.sdk.api.session.room.threads.ThreadsService
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId
import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId
import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread
import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.util.awaitTransaction
internal class DefaultThreadsService @AssistedInject constructor(
@Assisted private val roomId: String,
@UserId private val userId: String,
@SessionDatabase private val monarchy: Monarchy,
private val timelineEventMapper: TimelineEventMapper,
) : ThreadsService {
@AssistedFactory
interface Factory {
fun create(roomId: String): DefaultThreadsService
}
override fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>> {
return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getMarkedThreadNotifications(): List<TimelineEvent> {
return monarchy.fetchAllMappedSync(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> {
return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getAllThreads(): List<TimelineEvent> {
return monarchy.fetchAllMappedSync(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean {
return Realm.getInstance(monarchy.realmConfiguration).use {
TimelineEventEntity.isUserParticipatingInThread(
realm = it,
roomId = roomId,
rootThreadEventId = rootThreadEventId,
senderId = userId)
}
}
override fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> {
return Realm.getInstance(monarchy.realmConfiguration).use {
threads.mapEventsWithEdition(it, roomId)
}
}
override suspend fun markThreadAsRead(rootThreadEventId: String) {
monarchy.awaitTransaction {
EventEntity.where(
realm = it,
eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE
}
}
}

View File

@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler
@ -60,6 +61,7 @@ internal class DefaultTimeline(private val roomId: String,
timelineEventMapper: TimelineEventMapper, timelineEventMapper: TimelineEventMapper,
timelineInput: TimelineInput, timelineInput: TimelineInput,
threadsAwarenessHandler: ThreadsAwarenessHandler, threadsAwarenessHandler: ThreadsAwarenessHandler,
lightweightSettingsStorage: LightweightSettingsStorage,
eventDecryptor: TimelineEventDecryptor) : Timeline { eventDecryptor: TimelineEventDecryptor) : Timeline {
companion object { companion object {
@ -79,6 +81,9 @@ internal class DefaultTimeline(private val roomId: String,
private val sequencer = SemaphoreCoroutineSequencer() private val sequencer = SemaphoreCoroutineSequencer()
private val postSnapshotSignalFlow = MutableSharedFlow<Unit>(0) private val postSnapshotSignalFlow = MutableSharedFlow<Unit>(0)
private var isFromThreadTimeline = false
private var rootThreadEventId: String? = null
private val strategyDependencies = LoadTimelineStrategy.Dependencies( private val strategyDependencies = LoadTimelineStrategy.Dependencies(
timelineSettings = settings, timelineSettings = settings,
realm = backgroundRealm, realm = backgroundRealm,
@ -89,6 +94,7 @@ internal class DefaultTimeline(private val roomId: String,
timelineInput = timelineInput, timelineInput = timelineInput,
timelineEventMapper = timelineEventMapper, timelineEventMapper = timelineEventMapper,
threadsAwarenessHandler = threadsAwarenessHandler, threadsAwarenessHandler = threadsAwarenessHandler,
lightweightSettingsStorage = lightweightSettingsStorage,
onEventsUpdated = this::sendSignalToPostSnapshot, onEventsUpdated = this::sendSignalToPostSnapshot,
onLimitedTimeline = this::onLimitedTimeline, onLimitedTimeline = this::onLimitedTimeline,
onNewTimelineEvents = this::onNewTimelineEvents onNewTimelineEvents = this::onNewTimelineEvents
@ -118,18 +124,21 @@ internal class DefaultTimeline(private val roomId: String,
listeners.clear() listeners.clear()
} }
override fun start() { override fun start(rootThreadEventId: String?) {
timelineScope.launch { timelineScope.launch {
loadRoomMembersIfNeeded() loadRoomMembersIfNeeded()
} }
timelineScope.launch { timelineScope.launch {
sequencer.post { sequencer.post {
if (isStarted.compareAndSet(false, true)) { if (isStarted.compareAndSet(false, true)) {
isFromThreadTimeline = rootThreadEventId != null
this@DefaultTimeline.rootThreadEventId = rootThreadEventId
// /
val realm = Realm.getInstance(realmConfiguration) val realm = Realm.getInstance(realmConfiguration)
ensureReadReceiptAreLoaded(realm) ensureReadReceiptAreLoaded(realm)
backgroundRealm.set(realm) backgroundRealm.set(realm)
listenToPostSnapshotSignals() listenToPostSnapshotSignals()
openAround(initialEventId) openAround(initialEventId, rootThreadEventId)
postSnapshot() postSnapshot()
} }
} }
@ -150,7 +159,7 @@ internal class DefaultTimeline(private val roomId: String,
override fun restartWithEventId(eventId: String?) { override fun restartWithEventId(eventId: String?) {
timelineScope.launch { timelineScope.launch {
openAround(eventId) openAround(eventId, rootThreadEventId)
postSnapshot() postSnapshot()
} }
} }
@ -219,19 +228,24 @@ internal class DefaultTimeline(private val roomId: String,
return true return true
} }
private suspend fun openAround(eventId: String?) = withContext(timelineDispatcher) { private suspend fun openAround(eventId: String?, rootThreadEventId: String?) = withContext(timelineDispatcher) {
val baseLogMessage = "openAround(eventId: $eventId)" val baseLogMessage = "openAround(eventId: $eventId)"
Timber.v("$baseLogMessage started") Timber.v("$baseLogMessage started")
if (!isStarted.get()) { if (!isStarted.get()) {
throw IllegalStateException("You should call start before using timeline") throw IllegalStateException("You should call start before using timeline")
} }
strategy.onStop() strategy.onStop()
strategy = if (eventId == null) {
buildStrategy(LoadTimelineStrategy.Mode.Live) strategy = when {
} else { rootThreadEventId != null -> buildStrategy(LoadTimelineStrategy.Mode.Thread(rootThreadEventId))
buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId)) eventId == null -> buildStrategy(LoadTimelineStrategy.Mode.Live)
else -> buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId))
} }
initPaginationStates(eventId)
rootThreadEventId?.let {
initPaginationStates(null)
} ?: initPaginationStates(eventId)
strategy.onStart() strategy.onStart()
loadMore( loadMore(
count = strategyDependencies.timelineSettings.initialSize, count = strategyDependencies.timelineSettings.initialSize,

View File

@ -32,11 +32,13 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.database.RealmSessionProvider import org.matrix.android.sdk.internal.database.RealmSessionProvider
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
@ -44,6 +46,7 @@ import org.matrix.android.sdk.internal.task.TaskExecutor
internal class DefaultTimelineService @AssistedInject constructor( internal class DefaultTimelineService @AssistedInject constructor(
@Assisted private val roomId: String, @Assisted private val roomId: String,
@UserId private val userId: String,
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
private val realmSessionProvider: RealmSessionProvider, private val realmSessionProvider: RealmSessionProvider,
private val timelineInput: TimelineInput, private val timelineInput: TimelineInput,
@ -55,6 +58,7 @@ internal class DefaultTimelineService @AssistedInject constructor(
private val timelineEventMapper: TimelineEventMapper, private val timelineEventMapper: TimelineEventMapper,
private val loadRoomMembersTask: LoadRoomMembersTask, private val loadRoomMembersTask: LoadRoomMembersTask,
private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val readReceiptHandler: ReadReceiptHandler, private val readReceiptHandler: ReadReceiptHandler,
private val coroutineDispatchers: MatrixCoroutineDispatchers private val coroutineDispatchers: MatrixCoroutineDispatchers
) : TimelineService { ) : TimelineService {
@ -79,7 +83,8 @@ internal class DefaultTimelineService @AssistedInject constructor(
loadRoomMembersTask = loadRoomMembersTask, loadRoomMembersTask = loadRoomMembersTask,
readReceiptHandler = readReceiptHandler, readReceiptHandler = readReceiptHandler,
getEventTask = contextOfEventTask, getEventTask = contextOfEventTask,
threadsAwarenessHandler = threadsAwarenessHandler threadsAwarenessHandler = threadsAwarenessHandler,
lightweightSettingsStorage = lightweightSettingsStorage
) )
} }

View File

@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
@ -51,6 +52,7 @@ internal class LoadTimelineStrategy(
sealed interface Mode { sealed interface Mode {
object Live : Mode object Live : Mode
data class Permalink(val originEventId: String) : Mode data class Permalink(val originEventId: String) : Mode
data class Thread(val rootThreadEventId: String) : Mode
fun originEventId(): String? { fun originEventId(): String? {
return if (this is Permalink) { return if (this is Permalink) {
@ -59,6 +61,14 @@ internal class LoadTimelineStrategy(
null null
} }
} }
// fun getRootThreadEventId(): String? {
// return if (this is Thread) {
// rootThreadEventId
// } else {
// null
// }
// }
} }
data class Dependencies( data class Dependencies(
@ -71,6 +81,7 @@ internal class LoadTimelineStrategy(
val timelineInput: TimelineInput, val timelineInput: TimelineInput,
val timelineEventMapper: TimelineEventMapper, val timelineEventMapper: TimelineEventMapper,
val threadsAwarenessHandler: ThreadsAwarenessHandler, val threadsAwarenessHandler: ThreadsAwarenessHandler,
val lightweightSettingsStorage: LightweightSettingsStorage,
val onEventsUpdated: (Boolean) -> Unit, val onEventsUpdated: (Boolean) -> Unit,
val onLimitedTimeline: () -> Unit, val onLimitedTimeline: () -> Unit,
val onNewTimelineEvents: (List<String>) -> Unit val onNewTimelineEvents: (List<String>) -> Unit
@ -198,13 +209,21 @@ internal class LoadTimelineStrategy(
} }
private fun getChunkEntity(realm: Realm): RealmResults<ChunkEntity> { private fun getChunkEntity(realm: Realm): RealmResults<ChunkEntity> {
return if (mode is Mode.Permalink) { return when (mode) {
ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) is Mode.Live -> {
} else {
ChunkEntity.where(realm, roomId) ChunkEntity.where(realm, roomId)
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findAll() .findAll()
} }
is Mode.Permalink -> {
ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId))
}
is Mode.Thread -> {
ChunkEntity.where(realm, roomId)
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findAll()
}
}
} }
private fun hasReachedLastForward(): Boolean { private fun hasReachedLastForward(): Boolean {
@ -224,6 +243,7 @@ internal class LoadTimelineStrategy(
timelineEventMapper = dependencies.timelineEventMapper, timelineEventMapper = dependencies.timelineEventMapper,
uiEchoManager = uiEchoManager, uiEchoManager = uiEchoManager,
threadsAwarenessHandler = dependencies.threadsAwarenessHandler, threadsAwarenessHandler = dependencies.threadsAwarenessHandler,
lightweightSettingsStorage = dependencies.lightweightSettingsStorage,
initialEventId = mode.originEventId(), initialEventId = mode.originEventId(),
onBuiltEvents = dependencies.onEventsUpdated onBuiltEvents = dependencies.onEventsUpdated
) )

View File

@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.mapper.EventMapper
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity
@ -55,6 +56,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
private val timelineEventMapper: TimelineEventMapper, private val timelineEventMapper: TimelineEventMapper,
private val uiEchoManager: UIEchoManager? = null, private val uiEchoManager: UIEchoManager? = null,
private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val initialEventId: String?, private val initialEventId: String?,
private val onBuiltEvents: (Boolean) -> Unit) { private val onBuiltEvents: (Boolean) -> Unit) {
@ -92,7 +94,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
handleDatabaseChangeSet(frozenResults, changeSet) handleDatabaseChangeSet(frozenResults, changeSet)
} }
private var timelineEventEntities: RealmResults<TimelineEventEntity> = chunkEntity.sortedTimelineEvents() private var timelineEventEntities: RealmResults<TimelineEventEntity> = chunkEntity.sortedTimelineEvents(timelineSettings.rootThreadEventId)
private val builtEvents: MutableList<TimelineEvent> = Collections.synchronizedList(ArrayList()) private val builtEvents: MutableList<TimelineEvent> = Collections.synchronizedList(ArrayList())
private val builtEventsIndexes: MutableMap<String, Int> = Collections.synchronizedMap(HashMap<String, Int>()) private val builtEventsIndexes: MutableMap<String, Int> = Collections.synchronizedMap(HashMap<String, Int>())
@ -137,13 +139,18 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
} else if (direction == Timeline.Direction.BACKWARDS && prevChunk != null) { } else if (direction == Timeline.Direction.BACKWARDS && prevChunk != null) {
return prevChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE return prevChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE
} }
val loadFromStorageCount = loadFromStorage(count, direction) val loadFromStorage = loadFromStorage(count, direction).also {
Timber.v("Has loaded $loadFromStorageCount items from storage in $direction") logLoadedFromStorage(it, direction)
val offsetCount = count - loadFromStorageCount }
val offsetCount = count - loadFromStorage.numberOfEvents
return if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) { return if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) {
LoadMoreResult.REACHED_END LoadMoreResult.REACHED_END
} else if (direction == Timeline.Direction.BACKWARDS && isLastBackward.get()) { } else if (direction == Timeline.Direction.BACKWARDS && isLastBackward.get()) {
LoadMoreResult.REACHED_END LoadMoreResult.REACHED_END
} else if (timelineSettings.isThreadTimeline() && loadFromStorage.threadReachedEnd) {
LoadMoreResult.REACHED_END
} else if (offsetCount == 0) { } else if (offsetCount == 0) {
LoadMoreResult.SUCCESS LoadMoreResult.SUCCESS
} else { } else {
@ -187,6 +194,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
} }
} }
/**
* Simple log that displays the number and timeline of loaded events
*/
private fun logLoadedFromStorage(loadedFromStorage: LoadedFromStorage, direction: Timeline.Direction) {
Timber.v("[" +
"${if (timelineSettings.isThreadTimeline()) "ThreadTimeLine" else "Timeline"}] Has loaded " +
"${loadedFromStorage.numberOfEvents} items from storage in $direction " +
if (timelineSettings.isThreadTimeline() && loadedFromStorage.threadReachedEnd) "[Reached End]" else "")
}
fun getBuiltEventIndex(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): Int? { fun getBuiltEventIndex(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): Int? {
val builtEventIndex = builtEventsIndexes[eventId] val builtEventIndex = builtEventsIndexes[eventId]
if (builtEventIndex != null) { if (builtEventIndex != null) {
@ -267,13 +284,23 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
/** /**
* This method tries to read events from the current chunk. * This method tries to read events from the current chunk.
* @return the number of events loaded. If we are in a thread timeline it also returns
* whether or not we reached the end/root message
*/ */
private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): Int { private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): LoadedFromStorage {
val displayIndex = getNextDisplayIndex(direction) ?: return 0 val displayIndex = getNextDisplayIndex(direction) ?: return LoadedFromStorage()
val baseQuery = timelineEventEntities.where() val baseQuery = timelineEventEntities.where()
val timelineEvents = baseQuery.offsets(direction, count, displayIndex).findAll().orEmpty()
if (timelineEvents.isEmpty()) return 0 val timelineEvents = baseQuery
fetchRootThreadEventsIfNeeded(timelineEvents) .offsets(direction, count, displayIndex)
.findAll()
.orEmpty()
if (timelineEvents.isEmpty()) return LoadedFromStorage()
// Disabled due to the new fallback
// if(!lightweightSettingsStorage.areThreadMessagesEnabled()) {
// fetchRootThreadEventsIfNeeded(timelineEvents)
// }
if (direction == Timeline.Direction.FORWARDS) { if (direction == Timeline.Direction.FORWARDS) {
builtEventsIndexes.entries.forEach { it.setValue(it.value + timelineEvents.size) } builtEventsIndexes.entries.forEach { it.setValue(it.value + timelineEvents.size) }
} }
@ -291,9 +318,20 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
builtEvents.add(timelineEvent) builtEvents.add(timelineEvent)
} }
} }
return timelineEvents.size return LoadedFromStorage(
threadReachedEnd = threadReachedEnd(timelineEvents),
numberOfEvents = timelineEvents.size)
} }
/**
* Returns whether or not the the thread has reached end. It returns false if the current timeline
* is not a thread timeline
*/
private fun threadReachedEnd(timelineEvents: List<TimelineEventEntity>): Boolean =
timelineSettings.rootThreadEventId?.let { rootThreadId ->
timelineEvents.firstOrNull { it.eventId == rootThreadId }?.let { true }
} ?: false
/** /**
* This function is responsible to fetch and store the root event of a thread event * This function is responsible to fetch and store the root event of a thread event
* in order to be able to display the event to the user appropriately * in order to be able to display the event to the user appropriately
@ -316,6 +354,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.mxDecryptionResult == null) {
timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) } timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) }
} }
if (!timelineEvent.isEncrypted() && !lightweightSettingsStorage.areThreadMessagesEnabled()) {
// Thread aware for not encrypted events
timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) }
}
return timelineEvent return timelineEvent
} }
@ -343,7 +385,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
val loadMoreResult = try { val loadMoreResult = try {
if (token == null) { if (token == null) {
if (direction == Timeline.Direction.BACKWARDS || !chunkEntity.hasBeenALastForwardChunk()) return LoadMoreResult.REACHED_END if (direction == Timeline.Direction.BACKWARDS || !chunkEntity.hasBeenALastForwardChunk()) return LoadMoreResult.REACHED_END
val lastKnownEventId = chunkEntity.sortedTimelineEvents().firstOrNull()?.eventId ?: return LoadMoreResult.FAILURE val lastKnownEventId = chunkEntity.sortedTimelineEvents(timelineSettings.rootThreadEventId).firstOrNull()?.eventId
?: return LoadMoreResult.FAILURE
val taskParams = FetchTokenAndPaginateTask.Params(roomId, lastKnownEventId, direction.toPaginationDirection(), count) val taskParams = FetchTokenAndPaginateTask.Params(roomId, lastKnownEventId, direction.toPaginationDirection(), count)
fetchTokenAndPaginateTask.execute(taskParams).toLoadMoreResult() fetchTokenAndPaginateTask.execute(taskParams).toLoadMoreResult()
} else { } else {
@ -352,7 +395,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
paginationTask.execute(taskParams).toLoadMoreResult() paginationTask.execute(taskParams).toLoadMoreResult()
} }
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.e("Failed to fetch from server: $failure", failure) Timber.e(failure, "Failed to fetch from server")
LoadMoreResult.FAILURE LoadMoreResult.FAILURE
} }
return if (loadMoreResult == LoadMoreResult.SUCCESS) { return if (loadMoreResult == LoadMoreResult.SUCCESS) {
@ -450,10 +493,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
timelineEventMapper = timelineEventMapper, timelineEventMapper = timelineEventMapper,
uiEchoManager = uiEchoManager, uiEchoManager = uiEchoManager,
threadsAwarenessHandler = threadsAwarenessHandler, threadsAwarenessHandler = threadsAwarenessHandler,
lightweightSettingsStorage = lightweightSettingsStorage,
initialEventId = null, initialEventId = null,
onBuiltEvents = this.onBuiltEvents onBuiltEvents = this.onBuiltEvents
) )
} }
private data class LoadedFromStorage(
val threadReachedEnd: Boolean = false,
val numberOfEvents: Int = 0
)
} }
private fun RealmQuery<TimelineEventEntity>.offsets( private fun RealmQuery<TimelineEventEntity>.offsets(
@ -474,6 +523,19 @@ private fun Timeline.Direction.toPaginationDirection(): PaginationDirection {
return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS
} }
private fun ChunkEntity.sortedTimelineEvents(): RealmResults<TimelineEventEntity> { private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmResults<TimelineEventEntity> {
return timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) return if (rootThreadEventId == null) {
timelineEvents
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
} else {
timelineEvents
.where()
.beginGroup()
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.or()
.equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId)
.endGroup()
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll()
}
} }

View File

@ -23,6 +23,8 @@ import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.internal.crypto.NewSessionListener import org.matrix.android.sdk.internal.crypto.NewSessionListener
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
@ -36,7 +38,8 @@ internal class TimelineEventDecryptor @Inject constructor(
@SessionDatabase @SessionDatabase
private val realmConfiguration: RealmConfiguration, private val realmConfiguration: RealmConfiguration,
private val cryptoService: CryptoService, private val cryptoService: CryptoService,
private val threadsAwarenessHandler: ThreadsAwarenessHandler private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val lightweightSettingsStorage: LightweightSettingsStorage
) { ) {
private val newSessionListener = object : NewSessionListener { private val newSessionListener = object : NewSessionListener {
@ -101,9 +104,27 @@ internal class TimelineEventDecryptor @Inject constructor(
} }
} }
private fun threadAwareNonEncryptedEvents(request: DecryptionRequest, realm: Realm) {
val event = request.event
realm.executeTransaction {
val eventId = event.eventId ?: return@executeTransaction
val eventEntity = EventEntity
.where(it, eventId = eventId)
.findFirst()
val decryptedEvent = eventEntity?.asDomain()
threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity)
}
}
private fun processDecryptRequest(request: DecryptionRequest, realm: Realm) { private fun processDecryptRequest(request: DecryptionRequest, realm: Realm) {
val event = request.event val event = request.event
val timelineId = request.timelineId val timelineId = request.timelineId
if (!request.event.isEncrypted()) {
// Here we have requested a decryption to an event that is not encrypted
// We will simply make this event thread aware
threadAwareNonEncryptedEvents(request, realm)
return
}
try { try {
val result = cryptoService.decryptEvent(request.event, timelineId) val result = cryptoService.decryptEvent(request.event, timelineId)
Timber.v("Successfully decrypted event ${event.eventId}") Timber.v("Successfully decrypted event ${event.eventId}")
@ -112,15 +133,9 @@ internal class TimelineEventDecryptor @Inject constructor(
val eventEntity = EventEntity val eventEntity = EventEntity
.where(it, eventId = eventId) .where(it, eventId = eventId)
.findFirst() .findFirst()
eventEntity?.setDecryptionResult(result)
eventEntity?.apply { val decryptedEvent = eventEntity?.asDomain()
val decryptedPayload = threadsAwarenessHandler.handleIfNeededDuringDecryption( threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity)
it,
roomId = event.roomId,
event,
result)
setDecryptionResult(result, decryptedPayload)
}
} }
} catch (e: MXCryptoError) { } catch (e: MXCryptoError) {
Timber.v("Failed to decrypt event ${event.eventId} : ${e.localizedMessage}") Timber.v("Failed to decrypt event ${event.eventId} : ${e.localizedMessage}")

View File

@ -26,8 +26,11 @@ import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addIfNecessary
import org.matrix.android.sdk.internal.database.helper.addStateEvent import org.matrix.android.sdk.internal.database.helper.addStateEvent
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity
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.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
@ -36,6 +39,7 @@ import org.matrix.android.sdk.internal.database.query.create
import org.matrix.android.sdk.internal.database.query.find import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.StreamEventsManager import org.matrix.android.sdk.internal.session.StreamEventsManager
import org.matrix.android.sdk.internal.util.awaitTransaction import org.matrix.android.sdk.internal.util.awaitTransaction
import timber.log.Timber import timber.log.Timber
@ -46,6 +50,8 @@ import javax.inject.Inject
*/ */
internal class TokenChunkEventPersistor @Inject constructor( internal class TokenChunkEventPersistor @Inject constructor(
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
@UserId private val userId: String,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val liveEventManager: Lazy<StreamEventsManager>) { private val liveEventManager: Lazy<StreamEventsManager>) {
enum class Result { enum class Result {
@ -90,6 +96,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
handlePagination(realm, roomId, direction, receivedChunk, currentChunk) handlePagination(realm, roomId, direction, receivedChunk, currentChunk)
} }
} }
return if (receivedChunk.events.isEmpty()) { return if (receivedChunk.events.isEmpty()) {
if (receivedChunk.hasMore()) { if (receivedChunk.hasMore()) {
Result.SHOULD_FETCH_MORE Result.SHOULD_FETCH_MORE
@ -132,6 +139,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
roomMemberContentsByUser[stateEvent.stateKey] = stateEvent.content.toModel<RoomMemberContent>() roomMemberContentsByUser[stateEvent.stateKey] = stateEvent.content.toModel<RoomMemberContent>()
} }
} }
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
run processTimelineEvents@{ run processTimelineEvents@{
eventList.forEach { event -> eventList.forEach { event ->
if (event.eventId == null || event.senderId == null) { if (event.eventId == null || event.senderId == null) {
@ -176,10 +184,28 @@ internal class TokenChunkEventPersistor @Inject constructor(
} }
liveEventManager.get().dispatchPaginatedEventReceived(event, roomId) liveEventManager.get().dispatchPaginatedEventReceived(event, roomId)
currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser)
if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
eventEntity.rootThreadEventId?.let {
// This is a thread event
optimizedThreadSummaryMap[it] = eventEntity
} ?: run {
// This is a normal event or a root thread one
optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
}
}
} }
} }
if (currentChunk.isValid) { if (currentChunk.isValid) {
RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk) RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk)
} }
if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(
roomId = roomId,
realm = realm,
currentUserId = userId,
chunkEntity = currentChunk
)
}
} }
} }

View File

@ -19,6 +19,10 @@ package org.matrix.android.sdk.internal.session.search
import org.matrix.android.sdk.api.session.search.EventAndSender import org.matrix.android.sdk.api.session.search.EventAndSender
import org.matrix.android.sdk.api.session.search.SearchResult import org.matrix.android.sdk.api.session.search.SearchResult
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.database.RealmSessionProvider
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.search.request.SearchRequestBody import org.matrix.android.sdk.internal.session.search.request.SearchRequestBody
@ -28,6 +32,7 @@ import org.matrix.android.sdk.internal.session.search.request.SearchRequestFilte
import org.matrix.android.sdk.internal.session.search.request.SearchRequestOrder import org.matrix.android.sdk.internal.session.search.request.SearchRequestOrder
import org.matrix.android.sdk.internal.session.search.request.SearchRequestRoomEvents import org.matrix.android.sdk.internal.session.search.request.SearchRequestRoomEvents
import org.matrix.android.sdk.internal.session.search.response.SearchResponse import org.matrix.android.sdk.internal.session.search.response.SearchResponse
import org.matrix.android.sdk.internal.session.search.response.SearchResponseItem
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject import javax.inject.Inject
@ -47,7 +52,8 @@ internal interface SearchTask : Task<SearchTask.Params, SearchResult> {
internal class DefaultSearchTask @Inject constructor( internal class DefaultSearchTask @Inject constructor(
private val searchAPI: SearchAPI, private val searchAPI: SearchAPI,
private val globalErrorReceiver: GlobalErrorReceiver private val globalErrorReceiver: GlobalErrorReceiver,
private val realmSessionProvider: RealmSessionProvider
) : SearchTask { ) : SearchTask {
override suspend fun execute(params: SearchTask.Params): SearchResult { override suspend fun execute(params: SearchTask.Params): SearchResult {
@ -74,12 +80,22 @@ internal class DefaultSearchTask @Inject constructor(
} }
private fun SearchResponse.toDomain(): SearchResult { private fun SearchResponse.toDomain(): SearchResult {
val localTimelineEvents = findRootThreadEventsFromDB(searchCategories.roomEvents?.results)
return SearchResult( return SearchResult(
nextBatch = searchCategories.roomEvents?.nextBatch, nextBatch = searchCategories.roomEvents?.nextBatch,
highlights = searchCategories.roomEvents?.highlights, highlights = searchCategories.roomEvents?.highlights,
results = searchCategories.roomEvents?.results?.map { searchResponseItem -> results = searchCategories.roomEvents?.results?.map { searchResponseItem ->
val localThreadEventDetails = localTimelineEvents
?.firstOrNull { it.eventId == searchResponseItem.event.eventId }
?.root
?.asDomain()
?.threadDetails
EventAndSender( EventAndSender(
searchResponseItem.event, searchResponseItem.event.apply {
threadDetails = localThreadEventDetails
},
searchResponseItem.event.senderId?.let { senderId -> searchResponseItem.event.senderId?.let { senderId ->
searchResponseItem.context?.profileInfo?.get(senderId) searchResponseItem.context?.profileInfo?.get(senderId)
?.let { ?.let {
@ -94,4 +110,19 @@ internal class DefaultSearchTask @Inject constructor(
}?.reversed() }?.reversed()
) )
} }
/**
* Find local events if exists in order to enhance the result with thread summary
*/
private fun findRootThreadEventsFromDB(searchResponseItemList: List<SearchResponseItem>?): List<TimelineEventEntity>? {
return realmSessionProvider.withRealm { realm ->
searchResponseItemList?.mapNotNull {
it.event.roomId ?: return@mapNotNull null
it.event.eventId ?: return@mapNotNull null
TimelineEventEntity.where(realm, it.event.roomId, it.event.eventId).findFirst()
}?.filter {
it.root?.isRootThread == true || it.root?.isThread() == true
}
}
}
} }

View File

@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.sync.model.RoomsSyncResponse
import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.session.sync.model.SyncResponse
import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.SessionManager
import org.matrix.android.sdk.internal.crypto.DefaultCryptoService import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.SessionId
import org.matrix.android.sdk.internal.di.WorkManagerProvider import org.matrix.android.sdk.internal.di.WorkManagerProvider
@ -64,6 +65,7 @@ internal class SyncResponseHandler @Inject constructor(
private val aggregatorHandler: SyncResponsePostTreatmentAggregatorHandler, private val aggregatorHandler: SyncResponsePostTreatmentAggregatorHandler,
private val cryptoService: DefaultCryptoService, private val cryptoService: DefaultCryptoService,
private val tokenStore: SyncTokenStore, private val tokenStore: SyncTokenStore,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val processEventForPushTask: ProcessEventForPushTask, private val processEventForPushTask: ProcessEventForPushTask,
private val pushRuleService: PushRuleService, private val pushRuleService: PushRuleService,
private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val threadsAwarenessHandler: ThreadsAwarenessHandler,
@ -101,7 +103,10 @@ internal class SyncResponseHandler @Inject constructor(
val aggregator = SyncResponsePostTreatmentAggregator() val aggregator = SyncResponsePostTreatmentAggregator()
// Prerequisite for thread events handling in RoomSyncHandler // Prerequisite for thread events handling in RoomSyncHandler
threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) // Disabled due to the new fallback
// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) {
// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse)
// }
// Start one big transaction // Start one big transaction
monarchy.awaitTransaction { realm -> monarchy.awaitTransaction { realm ->

View File

@ -36,10 +36,13 @@ import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addIfNecessary
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity
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.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity
@ -81,6 +84,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource,
@UserId private val userId: String, @UserId private val userId: String,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val timelineInput: TimelineInput, private val timelineInput: TimelineInput,
private val liveEventService: Lazy<StreamEventsManager>) { private val liveEventService: Lazy<StreamEventsManager>) {
@ -363,10 +367,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
val eventIds = ArrayList<String>(eventList.size) val eventIds = ArrayList<String>(eventList.size)
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>() val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
for (event in eventList) { for (event in eventList) {
if (event.eventId == null || event.senderId == null || event.type == null) { if (event.eventId == null || event.senderId == null || event.type == null) {
continue continue
} }
eventIds.add(event.eventId) eventIds.add(event.eventId)
liveEventService.get().dispatchLiveEventReceived(event, roomId, insertType == EventInsertType.INITIAL_SYNC) liveEventService.get().dispatchLiveEventReceived(event, roomId, insertType == EventInsertType.INITIAL_SYNC)
@ -375,14 +381,13 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
if (event.isEncrypted() && !isInitialSync) { if (event.isEncrypted() && !isInitialSync) {
decryptIfNeeded(event, roomId) decryptIfNeeded(event, roomId)
} }
var contentToInject: String? = null
threadsAwarenessHandler.handleIfNeeded( if (!isInitialSync) {
realm = realm, contentToInject = threadsAwarenessHandler.makeEventThreadAware(realm, roomId, event)
roomId = roomId, }
event = event)
val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it }
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs, contentToInject).copyToRealmOrIgnore(realm, insertType)
if (event.stateKey != null) { if (event.stateKey != null) {
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {
eventId = event.eventId eventId = event.eventId
@ -402,6 +407,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
} }
chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
eventEntity.rootThreadEventId?.let {
// This is a thread event
optimizedThreadSummaryMap[it] = eventEntity
} ?: run {
// This is a normal event or a root thread one
optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
}
}
// Give info to crypto module // Give info to crypto module
cryptoService.onLiveEvent(roomEntity.roomId, event) cryptoService.onLiveEvent(roomEntity.roomId, event)
@ -426,9 +440,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
} }
} }
} }
// Handle deletion of [stuck] local echos if needed // Handle deletion of [stuck] local echos if needed
deleteLocalEchosIfNeeded(insertType, roomEntity, eventList) deleteLocalEchosIfNeeded(insertType, roomEntity, eventList)
if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(
roomId = roomId,
realm = realm,
chunkEntity = chunkEntity,
currentUserId = userId)
}
// posting new events to timeline if any is registered // posting new events to timeline if any is registered
timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds) timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds)

View File

@ -18,26 +18,35 @@ package org.matrix.android.sdk.internal.session.sync.handler.room
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import io.realm.Realm import io.realm.Realm
import org.matrix.android.sdk.api.session.crypto.CryptoService import io.realm.kotlin.where
import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContentForType
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.events.model.isSticker
import org.matrix.android.sdk.api.session.events.model.toContent 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.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat 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.MessageRelationContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.session.sync.model.SyncResponse
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.ContentMapper
import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.mapper.EventMapper
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventEntityFields
import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.MoshiProvider
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
@ -52,11 +61,16 @@ import javax.inject.Inject
*/ */
internal class ThreadsAwarenessHandler @Inject constructor( internal class ThreadsAwarenessHandler @Inject constructor(
private val permalinkFactory: PermalinkFactory, private val permalinkFactory: PermalinkFactory,
private val cryptoService: CryptoService,
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val getEventTask: GetEventTask private val getEventTask: GetEventTask
) { ) {
// This caching is responsible to improve the performance when we receive a root event
// to be able to know this event is a root one without checking the DB,
// We update the list with all thread root events by checking if there is a m.thread relation on the events
private val cacheEventRootId = hashSetOf<String>()
/** /**
* Fetch root thread events if they are missing from the local storage * Fetch root thread events if they are missing from the local storage
* @param syncResponse the sync response * @param syncResponse the sync response
@ -139,96 +153,186 @@ internal class ThreadsAwarenessHandler @Inject constructor(
/** /**
* Handle events mainly coming from the RoomSyncHandler * Handle events mainly coming from the RoomSyncHandler
* @return The content to inject in the roomSyncHandler live events
*/ */
fun handleIfNeeded(realm: Realm, fun makeEventThreadAware(realm: Realm,
roomId: String,
event: Event) {
val payload = transformThreadToReplyIfNeeded(
realm = realm,
roomId = roomId,
event = event,
decryptedResult = event.mxDecryptionResult?.payload) ?: return
event.mxDecryptionResult = event.mxDecryptionResult?.copy(payload = payload)
}
/**
* Handle events while they are being decrypted
*/
fun handleIfNeededDuringDecryption(realm: Realm,
roomId: String?, roomId: String?,
event: Event, event: Event?,
result: MXEventDecryptionResult): JsonDict? { eventEntity: EventEntity? = null): String? {
return transformThreadToReplyIfNeeded( event ?: return null
realm = realm, roomId ?: return null
if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null
handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event)
if (!isThreadEvent(event)) return null
val eventPayload = if (!event.isEncrypted()) {
event.content?.toMutableMap() ?: return null
} else {
event.mxDecryptionResult?.payload?.toMutableMap() ?: return null
}
val eventBody = event.getDecryptedTextSummary() ?: return null
val eventIdToInject = getPreviousEventOrRoot(event) ?: run {
return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload)
}
val eventToInject = getEventFromDB(realm, eventIdToInject)
val eventToInjectBody = eventToInject?.getDecryptedTextSummary()
var contentForNonEncrypted: String?
if (eventToInject != null && eventToInjectBody != null) {
// If the event to inject exists and is decrypted
// Inject it to our event
val messageTextContent = injectEvent(
roomId = roomId, roomId = roomId,
event = event, eventBody = eventBody,
decryptedResult = result.clearEvent) eventToInject = eventToInject,
eventToInjectBody = eventToInjectBody) ?: return null
// update the event
contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent)
} else {
contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload)
}
// Now lets try to find relations for improved results, while some events may come with reverse order
eventEntity?.let {
// When eventEntity is not null means that we are not from within roomSyncHandler
handleEventsThatRelatesTo(realm, roomId, event, eventBody, false)
}
return contentForNonEncrypted
} }
/** /**
* If the event is a thread event then transform/enhance it to a visual Reply Event, * Handle for not thread events that we have marked them as root.
* If the event is not a thread event, null value will be returned * Find relations and inject them accordingly
* If there is an error (ex. the root/origin thread event is not found), null willl be returend * @param eventEntity the current eventEntity received
* @param event the current event received
* @return The content to inject in the roomSyncHandler live events
*/ */
private fun transformThreadToReplyIfNeeded(realm: Realm, roomId: String?, event: Event, decryptedResult: JsonDict?): JsonDict? { private fun handleRootThreadEventsIfNeeded(realm: Realm, roomId: String, eventEntity: EventEntity?, event: Event): String? {
roomId ?: return null if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) {
if (!isThreadEvent(event)) return null eventEntity?.let {
val rootThreadEventId = getRootThreadEventId(event) ?: return null val eventBody = event.getDecryptedTextSummary() ?: return null
val payload = decryptedResult?.toMutableMap() ?: return null return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true)
val body = getValueFromPayload(payload, "body") ?: return null }
val msgType = getValueFromPayload(payload, "msgtype") ?: return null }
val rootThreadEvent = getEventFromDB(realm, rootThreadEventId) ?: return null return null
val rootThreadEventSenderId = rootThreadEvent.senderId ?: return null }
decryptIfNeeded(rootThreadEvent, roomId) /**
* This function is responsible to check if there is any event that relates to our current event
* This is useful when we receive an event that relates to a missing parent, so when later we receive the parent
* we can update the child as well
* @param event the current event that we examine
* @param eventBody the current body of the event
* @param isFromCache determines whether or not we already know this is root thread event
* @return The content to inject in the roomSyncHandler live events
*/
private fun handleEventsThatRelatesTo(realm: Realm, roomId: String, event: Event, eventBody: String, isFromCache: Boolean): String? {
event.eventId ?: return null
val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null
eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound ->
val newEventFound = eventEntityFound.asDomain()
val newEventBody = newEventFound.getDecryptedTextSummary() ?: return null
val newEventPayload = newEventFound.mxDecryptionResult?.payload?.toMutableMap() ?: return null
val rootThreadEventBody = getValueFromPayload(rootThreadEvent.mxDecryptionResult?.payload?.toMutableMap(), "body") val messageTextContent = injectEvent(
roomId = roomId,
eventBody = newEventBody,
eventToInject = event,
eventToInjectBody = eventBody) ?: return null
val permalink = permalinkFactory.createPermalink(roomId, rootThreadEventId, false) return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent)
val userLink = permalinkFactory.createPermalink(rootThreadEventSenderId, false) ?: "" }
return null
}
/**
* Actual update the eventEntity with the new payload
* @return the content to inject when this is executed by RoomSyncHandler
*/
private fun updateEventEntity(event: Event,
eventEntity: EventEntity?,
eventPayload: MutableMap<String, Any>,
messageTextContent: Content): String? {
eventPayload["content"] = messageTextContent
if (event.isEncrypted()) {
if (event.isSticker()) {
eventPayload["type"] = EventType.MESSAGE
}
event.mxDecryptionResult = event.mxDecryptionResult?.copy(payload = eventPayload)
eventEntity?.decryptionResultJson = event.mxDecryptionResult?.let {
MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).toJson(it)
}
} else {
if (event.type == EventType.STICKER) {
eventEntity?.type = EventType.MESSAGE
}
eventEntity?.content = ContentMapper.map(messageTextContent)
return ContentMapper.map(messageTextContent)
}
return null
}
/**
* Injecting $eventToInject decrypted content as a reply to $event
* @param eventToInject the event that will inject
* @param eventBody the actual event body
* @return The final content with the injected event
*/
private fun injectEvent(roomId: String,
eventBody: String,
eventToInject: Event,
eventToInjectBody: String): Content? {
val eventToInjectId = eventToInject.eventId ?: return null
val eventIdToInjectSenderId = eventToInject.senderId.orEmpty()
val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false)
val userLink = permalinkFactory.createPermalink(eventIdToInjectSenderId, false) ?: ""
val replyFormatted = LocalEchoEventFactory.REPLY_PATTERN.format( val replyFormatted = LocalEchoEventFactory.REPLY_PATTERN.format(
permalink, permalink,
userLink, userLink,
rootThreadEventSenderId, eventIdToInjectSenderId,
// Remove inner mx_reply tags if any eventToInjectBody,
rootThreadEventBody, eventBody)
body)
val messageTextContent = MessageTextContent( return MessageTextContent(
msgType = msgType, msgType = MessageType.MSGTYPE_TEXT,
format = MessageFormat.FORMAT_MATRIX_HTML, format = MessageFormat.FORMAT_MATRIX_HTML,
body = body, body = eventBody,
formattedBody = replyFormatted formattedBody = replyFormatted
).toContent() ).toContent()
payload["content"] = messageTextContent
return payload
} }
/** /**
* Decrypt the event * Integrate fallback Quote reply
*/ */
private fun injectFallbackIndicator(event: Event,
eventBody: String,
eventEntity: EventEntity?,
eventPayload: MutableMap<String, Any>): String? {
val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format(
"In reply to a thread",
eventBody)
private fun decryptIfNeeded(event: Event, roomId: String) { val messageTextContent = MessageTextContent(
try { msgType = MessageType.MSGTYPE_TEXT,
if (!event.isEncrypted() || event.mxDecryptionResult != null) return format = MessageFormat.FORMAT_MATRIX_HTML,
body = eventBody,
formattedBody = replyFormatted
).toContent()
// Event from sync does not have roomId, so add it to the event first return updateEventEntity(event, eventEntity, eventPayload, messageTextContent)
val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "")
event.mxDecryptionResult = OlmDecryptionResult(
payload = result.clearEvent,
senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
)
} catch (e: MXCryptoError) {
if (e is MXCryptoError.Base) {
event.mCryptoError = e.errorType
event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription
} }
private fun eventThatRelatesTo(realm: Realm, currentEventId: String, rootThreadEventId: String): List<EventEntity>? {
val threadList = realm.where<EventEntity>()
.beginGroup()
.equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.or()
.equalTo(EventEntityFields.EVENT_ID, rootThreadEventId)
.endGroup()
.and()
.findAll()
cacheEventRootId.add(rootThreadEventId)
return threadList.filter {
it.asDomain().getRelationContentForType(RelationType.IO_THREAD)?.inReplyTo?.eventId == currentEventId
} }
} }
@ -246,7 +350,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
* @param event * @param event
*/ */
private fun isThreadEvent(event: Event): Boolean = private fun isThreadEvent(event: Event): Boolean =
event.content.toModel<MessageRelationContent>()?.relatesTo?.type == RelationType.THREAD event.content.toModel<MessageRelationContent>()?.relatesTo?.type == RelationType.IO_THREAD
/** /**
* Returns the root thread eventId or null otherwise * Returns the root thread eventId or null otherwise
@ -255,6 +359,9 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun getRootThreadEventId(event: Event): String? = private fun getRootThreadEventId(event: Event): String? =
event.content.toModel<MessageRelationContent>()?.relatesTo?.eventId event.content.toModel<MessageRelationContent>()?.relatesTo?.eventId
private fun getPreviousEventOrRoot(event: Event): String? =
event.content.toModel<MessageRelationContent>()?.relatesTo?.inReplyTo?.eventId
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun getValueFromPayload(payload: JsonDict?, key: String): String? { private fun getValueFromPayload(payload: JsonDict?, key: String): String? {
val content = payload?.get("content") as? JsonDict val content = payload?.get("content") as? JsonDict

View File

@ -27,7 +27,6 @@ import org.matrix.android.sdk.internal.di.WorkManagerProvider
import org.matrix.android.sdk.internal.session.SessionComponent import org.matrix.android.sdk.internal.session.SessionComponent
import org.matrix.android.sdk.internal.session.sync.SyncPresence import org.matrix.android.sdk.internal.session.sync.SyncPresence
import org.matrix.android.sdk.internal.session.sync.SyncTask import org.matrix.android.sdk.internal.session.sync.SyncTask
import org.matrix.android.sdk.internal.task.TaskExecutor
import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker
import org.matrix.android.sdk.internal.worker.SessionWorkerParams import org.matrix.android.sdk.internal.worker.SessionWorkerParams
import org.matrix.android.sdk.internal.worker.WorkerParamsFactory import org.matrix.android.sdk.internal.worker.WorkerParamsFactory
@ -58,7 +57,6 @@ internal class SyncWorker(context: Context, workerParameters: WorkerParameters,
) : SessionWorkerParams ) : SessionWorkerParams
@Inject lateinit var syncTask: SyncTask @Inject lateinit var syncTask: SyncTask
@Inject lateinit var taskExecutor: TaskExecutor
@Inject lateinit var workManagerProvider: WorkManagerProvider @Inject lateinit var workManagerProvider: WorkManagerProvider
override fun injectWith(injector: SessionComponent) { override fun injectWith(injector: SessionComponent) {

0
tools/check/forbidden_strings_in_code.txt Normal file → Executable file
View File

2
tools/check/forbidden_strings_in_layout.txt Normal file → Executable file
View File

@ -24,7 +24,7 @@
# Extension:xml # Extension:xml
### Use style="@style/Widget.Vector.TextView.*" instead of textSize attribute ### Use style="@style/Widget.Vector.TextView.*" instead of textSize attribute
android:textSize===9 android:textSize===11
### Use `@id` and not `@+id` when referencing ids in layouts ### Use `@id` and not `@+id` when referencing ids in layouts
layout_(.*)="@\+id layout_(.*)="@\+id

View File

@ -153,6 +153,9 @@ android {
// This *must* only be set in trusted environments. // This *must* only be set in trusted environments.
buildConfigField "Boolean", "handleCallAssertedIdentityEvents", "false" buildConfigField "Boolean", "handleCallAssertedIdentityEvents", "false"
// Indicates whether or not threading support is enabled
buildConfigField "Boolean", "THREADING_ENABLED", "${isThreadingEnabled}"
buildConfigField "Boolean", "enableLocationSharing", "true" buildConfigField "Boolean", "enableLocationSharing", "true"
buildConfigField "String", "mapTilerKey", "\"fU3vlMsMn4Jb6dnEIFsx\"" buildConfigField "String", "mapTilerKey", "\"fU3vlMsMn4Jb6dnEIFsx\""
@ -288,9 +291,8 @@ android {
} }
} }
lintOptions { lint {
lintConfig file("lint.xml") lintConfig file('lint.xml')
checkDependencies true checkDependencies true
abortOnError true abortOnError true
} }

View File

@ -6,6 +6,7 @@
<issue id="MissingTranslation" severity="ignore" /> <issue id="MissingTranslation" severity="ignore" />
<issue id="TypographyEllipsis" severity="error" /> <issue id="TypographyEllipsis" severity="error" />
<issue id="ImpliedQuantity" severity="warning" /> <issue id="ImpliedQuantity" severity="warning" />
<issue id="MissingQuantity" severity="warning" />
<issue id="UnusedQuantity" severity="error" /> <issue id="UnusedQuantity" severity="error" />
<issue id="IconXmlAndPng" severity="error" /> <issue id="IconXmlAndPng" severity="error" />
<issue id="IconDipSize" severity="error" /> <issue id="IconDipSize" severity="error" />

View File

@ -145,7 +145,7 @@ class ElementRobot {
assertDisplayed(R.string.are_you_sure) assertDisplayed(R.string.are_you_sure)
clickOn(R.string.action_skip) clickOn(R.string.action_skip)
waitUntilViewVisible(withId(R.id.bottomSheetFragmentContainer)) waitUntilViewVisible(withId(R.id.bottomSheetFragmentContainer))
}.onFailure { Timber.w("Verification popup missing", it) } }.onFailure { Timber.w(it, "Verification popup missing") }
} }
} }

View File

@ -175,6 +175,8 @@
<activity android:name=".features.roomdirectory.RoomDirectoryActivity" /> <activity android:name=".features.roomdirectory.RoomDirectoryActivity" />
<activity android:name=".features.roomdirectory.roompreview.RoomPreviewActivity" /> <activity android:name=".features.roomdirectory.roompreview.RoomPreviewActivity" />
<activity android:name=".features.home.room.filtered.FilteredRoomsActivity" /> <activity android:name=".features.home.room.filtered.FilteredRoomsActivity" />
<activity android:name=".features.home.room.threads.ThreadsActivity" />
<activity <activity
android:name=".features.home.room.detail.RoomDetailActivity" android:name=".features.home.room.detail.RoomDetailActivity"
android:parentActivityName=".features.home.HomeActivity"> android:parentActivityName=".features.home.HomeActivity">

View File

@ -58,9 +58,10 @@ import im.vector.app.features.home.HomeDetailFragment
import im.vector.app.features.home.HomeDrawerFragment import im.vector.app.features.home.HomeDrawerFragment
import im.vector.app.features.home.LoadingFragment import im.vector.app.features.home.LoadingFragment
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment
import im.vector.app.features.home.room.detail.RoomDetailFragment import im.vector.app.features.home.room.detail.TimelineFragment
import im.vector.app.features.home.room.detail.search.SearchFragment import im.vector.app.features.home.room.detail.search.SearchFragment
import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.home.room.list.RoomListFragment
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
import im.vector.app.features.location.LocationPreviewFragment import im.vector.app.features.location.LocationPreviewFragment
import im.vector.app.features.location.LocationSharingFragment import im.vector.app.features.location.LocationSharingFragment
import im.vector.app.features.login.LoginCaptchaFragment import im.vector.app.features.login.LoginCaptchaFragment
@ -204,8 +205,8 @@ interface FragmentModule {
@Binds @Binds
@IntoMap @IntoMap
@FragmentKey(RoomDetailFragment::class) @FragmentKey(TimelineFragment::class)
fun bindRoomDetailFragment(fragment: RoomDetailFragment): Fragment fun bindTimelineFragment(fragment: TimelineFragment): Fragment
@Binds @Binds
@IntoMap @IntoMap
@ -937,6 +938,11 @@ interface FragmentModule {
@FragmentKey(SpaceLeaveAdvancedFragment::class) @FragmentKey(SpaceLeaveAdvancedFragment::class)
fun bindSpaceLeaveAdvancedFragment(fragment: SpaceLeaveAdvancedFragment): Fragment fun bindSpaceLeaveAdvancedFragment(fragment: SpaceLeaveAdvancedFragment): Fragment
@Binds
@IntoMap
@FragmentKey(ThreadListFragment::class)
fun bindThreadListFragment(fragment: ThreadListFragment): Fragment
@Binds @Binds
@IntoMap @IntoMap
@FragmentKey(CreatePollFragment::class) @FragmentKey(CreatePollFragment::class)

View File

@ -44,7 +44,7 @@ import im.vector.app.features.home.UnknownDeviceDetectorSharedViewModel
import im.vector.app.features.home.UnreadMessagesSharedViewModel import im.vector.app.features.home.UnreadMessagesSharedViewModel
import im.vector.app.features.home.UserColorAccountDataViewModel import im.vector.app.features.home.UserColorAccountDataViewModel
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel
import im.vector.app.features.home.room.detail.RoomDetailViewModel import im.vector.app.features.home.room.detail.TimelineViewModel
import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel
import im.vector.app.features.home.room.detail.search.SearchViewModel import im.vector.app.features.home.room.detail.search.SearchViewModel
import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel
@ -61,6 +61,7 @@ import im.vector.app.features.login2.created.AccountCreatedViewModel
import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel
import im.vector.app.features.onboarding.OnboardingViewModel import im.vector.app.features.onboarding.OnboardingViewModel
import im.vector.app.features.poll.create.CreatePollViewModel import im.vector.app.features.poll.create.CreatePollViewModel
import im.vector.app.features.qrcode.QrCodeScannerViewModel
import im.vector.app.features.rageshake.BugReportViewModel import im.vector.app.features.rageshake.BugReportViewModel
import im.vector.app.features.reactions.EmojiSearchResultViewModel import im.vector.app.features.reactions.EmojiSearchResultViewModel
import im.vector.app.features.room.RequireActiveMembershipViewModel import im.vector.app.features.room.RequireActiveMembershipViewModel
@ -220,6 +221,11 @@ interface MavericksViewModelModule {
@MavericksViewModelKey(CreateDirectRoomViewModel::class) @MavericksViewModelKey(CreateDirectRoomViewModel::class)
fun createDirectRoomViewModelFactory(factory: CreateDirectRoomViewModel.Factory): MavericksAssistedViewModelFactory<*, *> fun createDirectRoomViewModelFactory(factory: CreateDirectRoomViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(QrCodeScannerViewModel::class)
fun qrCodeViewModelFactory(factory: QrCodeScannerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds @Binds
@IntoMap @IntoMap
@MavericksViewModelKey(RoomNotificationSettingsViewModel::class) @MavericksViewModelKey(RoomNotificationSettingsViewModel::class)
@ -537,8 +543,8 @@ interface MavericksViewModelModule {
@Binds @Binds
@IntoMap @IntoMap
@MavericksViewModelKey(RoomDetailViewModel::class) @MavericksViewModelKey(TimelineViewModel::class)
fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *> fun roomDetailViewModelFactory(factory: TimelineViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds @Binds
@IntoMap @IntoMap

View File

@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import im.vector.app.R
fun ComponentActivity.registerStartForActivityResult(onResult: (ActivityResult) -> Unit): ActivityResultLauncher<Intent> { fun ComponentActivity.registerStartForActivityResult(onResult: (ActivityResult) -> Unit): ActivityResultLauncher<Intent> {
return registerForActivityResult(ActivityResultContracts.StartActivityForResult(), onResult) return registerForActivityResult(ActivityResultContracts.StartActivityForResult(), onResult)
@ -66,8 +67,12 @@ fun <T : Fragment> AppCompatActivity.replaceFragment(
fragmentClass: Class<T>, fragmentClass: Class<T>,
params: Parcelable? = null, params: Parcelable? = null,
tag: String? = null, tag: String? = null,
allowStateLoss: Boolean = false) { allowStateLoss: Boolean = false,
useCustomAnimation: Boolean = false) {
supportFragmentManager.commitTransaction(allowStateLoss) { supportFragmentManager.commitTransaction(allowStateLoss) {
if (useCustomAnimation) {
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
}
replace(container.id, fragmentClass, params.toMvRxBundle(), tag) replace(container.id, fragmentClass, params.toMvRxBundle(), tag)
} }
} }

View File

@ -129,6 +129,10 @@ fun TextView.setLeftDrawable(drawable: Drawable?) {
setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null)
} }
fun TextView.clearDrawables() {
setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
/** /**
* Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar * Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar
*/ */

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2022 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.app.core.platform
import com.airbnb.mvrx.MavericksState
data class VectorDummyViewState(
val isDummy: Unit = Unit
) : MavericksState

View File

@ -48,4 +48,8 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences:
fun shouldShowAvatarDisplayNameChanges(): Boolean { fun shouldShowAvatarDisplayNameChanges(): Boolean {
return vectorPreferences.showAvatarDisplayNameChangeMessages() return vectorPreferences.showAvatarDisplayNameChangeMessages()
} }
fun areThreadMessagesEnabled(): Boolean {
return vectorPreferences.areThreadMessagesEnabled()
}
} }

View File

@ -17,7 +17,6 @@
package im.vector.app.features.analytics.accountdata package im.vector.app.features.analytics.accountdata
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -26,6 +25,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorDummyViewState
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.VectorAnalytics
import im.vector.app.features.analytics.log.analyticsTag import im.vector.app.features.analytics.log.analyticsTag
@ -42,24 +42,20 @@ import org.matrix.android.sdk.flow.flow
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
data class DummyState(
val dummy: Boolean = false
) : MavericksState
class AnalyticsAccountDataViewModel @AssistedInject constructor( class AnalyticsAccountDataViewModel @AssistedInject constructor(
@Assisted initialState: DummyState, @Assisted initialState: VectorDummyViewState,
private val session: Session, private val session: Session,
private val analytics: VectorAnalytics private val analytics: VectorAnalytics
) : VectorViewModel<DummyState, EmptyAction, EmptyViewEvents>(initialState) { ) : VectorViewModel<VectorDummyViewState, EmptyAction, EmptyViewEvents>(initialState) {
private var checkDone: Boolean = false private var checkDone: Boolean = false
@AssistedFactory @AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<AnalyticsAccountDataViewModel, DummyState> { interface Factory : MavericksAssistedViewModelFactory<AnalyticsAccountDataViewModel, VectorDummyViewState> {
override fun create(initialState: DummyState): AnalyticsAccountDataViewModel override fun create(initialState: VectorDummyViewState): AnalyticsAccountDataViewModel
} }
companion object : MavericksViewModelFactory<AnalyticsAccountDataViewModel, DummyState> by hiltMavericksViewModelFactory() { companion object : MavericksViewModelFactory<AnalyticsAccountDataViewModel, VectorDummyViewState> by hiltMavericksViewModelFactory() {
private const val ANALYTICS_EVENT_TYPE = "im.vector.analytics" private const val ANALYTICS_EVENT_TYPE = "im.vector.analytics"
} }

View File

@ -18,17 +18,26 @@ package im.vector.app.features.autocomplete.command
import android.content.Context import android.content.Context
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.AutocompleteClickListener
import im.vector.app.features.autocomplete.RecyclerViewPresenter import im.vector.app.features.autocomplete.RecyclerViewPresenter
import im.vector.app.features.command.Command import im.vector.app.features.command.Command
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import javax.inject.Inject
class AutocompleteCommandPresenter @Inject constructor(context: Context, class AutocompleteCommandPresenter @AssistedInject constructor(
@Assisted val isInThreadTimeline: Boolean,
context: Context,
private val controller: AutocompleteCommandController, private val controller: AutocompleteCommandController,
private val vectorPreferences: VectorPreferences) : private val vectorPreferences: VectorPreferences) :
RecyclerViewPresenter<Command>(context), AutocompleteClickListener<Command> { RecyclerViewPresenter<Command>(context), AutocompleteClickListener<Command> {
@AssistedFactory
interface Factory {
fun create(isFromThreadTimeline: Boolean): AutocompleteCommandPresenter
}
init { init {
controller.listener = this controller.listener = this
} }
@ -46,6 +55,13 @@ class AutocompleteCommandPresenter @Inject constructor(context: Context,
.filter { .filter {
!it.isDevCommand || vectorPreferences.developerMode() !it.isDevCommand || vectorPreferences.developerMode()
} }
.filter {
if (vectorPreferences.areThreadMessagesEnabled() && isInThreadTimeline) {
it.isThreadCommand
} else {
true
}
}
.filter { .filter {
if (query.isNullOrEmpty()) { if (query.isNullOrEmpty()) {
true true

View File

@ -60,7 +60,7 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.displayname.getBestName import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailActivity
import im.vector.app.features.home.room.detail.RoomDetailArgs import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import io.github.hyuwah.draggableviewlib.DraggableView import io.github.hyuwah.draggableviewlib.DraggableView
import io.github.hyuwah.draggableviewlib.setupDraggable import io.github.hyuwah.draggableviewlib.setupDraggable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -571,7 +571,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private fun returnToChat() { private fun returnToChat() {
val roomId = withState(callViewModel) { it.roomId } val roomId = withState(callViewModel) { it.roomId }
val args = RoomDetailArgs(roomId) val args = TimelineArgs(roomId)
val intent = RoomDetailActivity.newIntent(this, args).apply { val intent = RoomDetailActivity.newIntent(this, args).apply {
flags = FLAG_ACTIVITY_CLEAR_TOP flags = FLAG_ACTIVITY_CLEAR_TOP
} }

View File

@ -28,41 +28,42 @@ enum class Command(val command: String,
val aliases: Array<CharSequence>?, val aliases: Array<CharSequence>?,
val parameters: String, val parameters: String,
@StringRes val description: Int, @StringRes val description: Int,
val isDevCommand: Boolean) { val isDevCommand: Boolean,
EMOTE("/me", null, "<message>", R.string.command_description_emote, false), val isThreadCommand: Boolean) {
BAN_USER("/ban", null, "<user-id> [reason]", R.string.command_description_ban_user, false), EMOTE("/me", null, "<message>", R.string.command_description_emote, false, true),
UNBAN_USER("/unban", null, "<user-id> [reason]", R.string.command_description_unban_user, false), BAN_USER("/ban", null, "<user-id> [reason]", R.string.command_description_ban_user, false, false),
IGNORE_USER("/ignore", null, "<user-id> [reason]", R.string.command_description_ignore_user, false), UNBAN_USER("/unban", null, "<user-id> [reason]", R.string.command_description_unban_user, false, false),
UNIGNORE_USER("/unignore", null, "<user-id>", R.string.command_description_unignore_user, false), IGNORE_USER("/ignore", null, "<user-id> [reason]", R.string.command_description_ignore_user, false, true),
SET_USER_POWER_LEVEL("/op", null, "<user-id> [<power-level>]", R.string.command_description_op_user, false), UNIGNORE_USER("/unignore", null, "<user-id>", R.string.command_description_unignore_user, false, true),
RESET_USER_POWER_LEVEL("/deop", null, "<user-id>", R.string.command_description_deop_user, false), SET_USER_POWER_LEVEL("/op", null, "<user-id> [<power-level>]", R.string.command_description_op_user, false, false),
ROOM_NAME("/roomname", null, "<name>", R.string.command_description_room_name, false), RESET_USER_POWER_LEVEL("/deop", null, "<user-id>", R.string.command_description_deop_user, false, false),
INVITE("/invite", null, "<user-id> [reason]", R.string.command_description_invite_user, false), ROOM_NAME("/roomname", null, "<name>", R.string.command_description_room_name, false, false),
JOIN_ROOM("/join", arrayOf("/j", "/goto"), "<room-address> [reason]", R.string.command_description_join_room, false), INVITE("/invite", null, "<user-id> [reason]", R.string.command_description_invite_user, false, false),
PART("/part", null, "[<room-address>]", R.string.command_description_part_room, false), JOIN_ROOM("/join", arrayOf("/j", "/goto"), "<room-address> [reason]", R.string.command_description_join_room, false, false),
TOPIC("/topic", null, "<topic>", R.string.command_description_topic, false), PART("/part", null, "[<room-address>]", R.string.command_description_part_room, false, false),
REMOVE_USER("/remove", arrayOf("/kick"), "<user-id> [reason]", R.string.command_description_remove_user, false), TOPIC("/topic", null, "<topic>", R.string.command_description_topic, false, false),
CHANGE_DISPLAY_NAME("/nick", null, "<display-name>", R.string.command_description_nick, false), REMOVE_USER("/remove", arrayOf("/kick"), "<user-id> [reason]", R.string.command_description_remove_user, false, false),
CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "<display-name>", R.string.command_description_nick_for_room, false), CHANGE_DISPLAY_NAME("/nick", null, "<display-name>", R.string.command_description_nick, false, false),
ROOM_AVATAR("/roomavatar", null, "<mxc_url>", R.string.command_description_room_avatar, true /* Since user has to know the mxc url */), CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "<display-name>", R.string.command_description_nick_for_room, false, false),
CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "<mxc_url>", R.string.command_description_avatar_for_room, true /* Since user has to know the mxc url */), ROOM_AVATAR("/roomavatar", null, "<mxc_url>", R.string.command_description_room_avatar, true /* User has to know the mxc url */, false),
MARKDOWN("/markdown", null, "<on|off>", R.string.command_description_markdown, false), CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "<mxc_url>", R.string.command_description_avatar_for_room, true /* User has to know the mxc url */, false),
RAINBOW("/rainbow", null, "<message>", R.string.command_description_rainbow, false), MARKDOWN("/markdown", null, "<on|off>", R.string.command_description_markdown, false, false),
RAINBOW_EMOTE("/rainbowme", null, "<message>", R.string.command_description_rainbow_emote, false), RAINBOW("/rainbow", null, "<message>", R.string.command_description_rainbow, false, true),
CLEAR_SCALAR_TOKEN("/clear_scalar_token", null, "", R.string.command_description_clear_scalar_token, false), RAINBOW_EMOTE("/rainbowme", null, "<message>", R.string.command_description_rainbow_emote, false, true),
SPOILER("/spoiler", null, "<message>", R.string.command_description_spoiler, false), CLEAR_SCALAR_TOKEN("/clear_scalar_token", null, "", R.string.command_description_clear_scalar_token, false, false),
SHRUG("/shrug", null, "<message>", R.string.command_description_shrug, false), SPOILER("/spoiler", null, "<message>", R.string.command_description_spoiler, false, true),
LENNY("/lenny", null, "<message>", R.string.command_description_lenny, false), SHRUG("/shrug", null, "<message>", R.string.command_description_shrug, false, true),
PLAIN("/plain", null, "<message>", R.string.command_description_plain, false), LENNY("/lenny", null, "<message>", R.string.command_description_lenny, false, true),
WHOIS("/whois", null, "<user-id>", R.string.command_description_whois, false), PLAIN("/plain", null, "<message>", R.string.command_description_plain, false, true),
DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false), WHOIS("/whois", null, "<user-id>", R.string.command_description_whois, false, true),
CONFETTI("/confetti", null, "<message>", R.string.command_confetti, false), DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false, false),
SNOWFALL("/snowfall", null, "<message>", R.string.command_snow, false), CONFETTI("/confetti", null, "<message>", R.string.command_confetti, false, false),
CREATE_SPACE("/createspace", null, "<name> <invitee>*", R.string.command_description_create_space, true), SNOWFALL("/snowfall", null, "<message>", R.string.command_snow, false, false),
ADD_TO_SPACE("/addToSpace", null, "spaceId", R.string.command_description_add_to_space, true), CREATE_SPACE("/createspace", null, "<name> <invitee>*", R.string.command_description_create_space, true, false),
JOIN_SPACE("/joinSpace", null, "spaceId", R.string.command_description_join_space, true), ADD_TO_SPACE("/addToSpace", null, "spaceId", R.string.command_description_add_to_space, true, false),
LEAVE_ROOM("/leave", null, "<roomId?>", R.string.command_description_leave_room, true), JOIN_SPACE("/joinSpace", null, "spaceId", R.string.command_description_join_space, true, false),
UPGRADE_ROOM("/upgraderoom", null, "newVersion", R.string.command_description_upgrade_room, true); LEAVE_ROOM("/leave", null, "<roomId?>", R.string.command_description_leave_room, true, false),
UPGRADE_ROOM("/upgraderoom", null, "newVersion", R.string.command_description_upgrade_room, true, false);
val allAliases = arrayOf(command, *aliases.orEmpty()) val allAliases = arrayOf(command, *aliases.orEmpty())

View File

@ -33,7 +33,7 @@ class CommandParser @Inject constructor() {
* @param textMessage the text message * @param textMessage the text message
* @return a parsed slash command (ok or error) * @return a parsed slash command (ok or error)
*/ */
fun parseSlashCommand(textMessage: CharSequence): ParsedCommand { fun parseSlashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand {
// check if it has the Slash marker // check if it has the Slash marker
return if (!textMessage.startsWith("/")) { return if (!textMessage.startsWith("/")) {
ParsedCommand.ErrorNotACommand ParsedCommand.ErrorNotACommand
@ -63,6 +63,10 @@ class CommandParser @Inject constructor() {
val slashCommand = messageParts.first() val slashCommand = messageParts.first()
val message = textMessage.substring(slashCommand.length).trim() val message = textMessage.substring(slashCommand.length).trim()
getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
return ParsedCommand.ErrorCommandNotSupportedInThreads(it)
}
when { when {
Command.PLAIN.matches(slashCommand) -> { Command.PLAIN.matches(slashCommand) -> {
if (message.isNotEmpty()) { if (message.isNotEmpty()) {
@ -400,6 +404,28 @@ class CommandParser @Inject constructor() {
} }
} }
private val notSupportedThreadsCommands: List<Command> by lazy {
Command.values().filter {
!it.isThreadCommand
}
}
/**
* Checks whether or not the current command is not supported by threads
* @param slashCommand the slash command that will be checked
* @param isInThreadTimeline if its true we are in a thread timeline
* @return The command that is not supported
*/
private fun getNotSupportedByThreads(isInThreadTimeline: Boolean, slashCommand: String): Command? {
return if (isInThreadTimeline) {
notSupportedThreadsCommands.firstOrNull {
it.command == slashCommand
}
} else {
null
}
}
private fun trimParts(message: CharSequence, messageParts: List<String>): String? { private fun trimParts(message: CharSequence, messageParts: List<String>): String? {
val partsSize = messageParts.sumOf { it.length } val partsSize = messageParts.sumOf { it.length }
val gapsNumber = messageParts.size - 1 val gapsNumber = messageParts.size - 1

View File

@ -28,6 +28,8 @@ sealed interface ParsedCommand {
object ErrorEmptySlashCommand : ParsedCommand object ErrorEmptySlashCommand : ParsedCommand
class ErrorCommandNotSupportedInThreads(val command: Command) : ParsedCommand
// Unknown/Unsupported slash command // Unknown/Unsupported slash command
data class ErrorUnknownSlashCommand(val slashCommand: String) : ParsedCommand data class ErrorUnknownSlashCommand(val slashCommand: String) : ParsedCommand

View File

@ -23,4 +23,8 @@ sealed class CreateDirectRoomAction : VectorViewModelAction {
data class CreateRoomAndInviteSelectedUsers( data class CreateRoomAndInviteSelectedUsers(
val selections: Set<PendingSelection> val selections: Set<PendingSelection>
) : CreateDirectRoomAction() ) : CreateDirectRoomAction()
data class QrScannedAction(
val result: String
) : CreateDirectRoomAction()
} }

View File

@ -22,6 +22,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Async import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
@ -44,6 +45,10 @@ import im.vector.app.core.utils.onPermissionDeniedSnackbar
import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.analytics.plan.Screen
import im.vector.app.features.contactsbook.ContactsBookFragment import im.vector.app.features.contactsbook.ContactsBookFragment
import im.vector.app.features.qrcode.QrCodeScannerEvents
import im.vector.app.features.qrcode.QrCodeScannerFragment
import im.vector.app.features.qrcode.QrCodeScannerViewModel
import im.vector.app.features.qrcode.QrScannerArgs
import im.vector.app.features.userdirectory.UserListFragment import im.vector.app.features.userdirectory.UserListFragment
import im.vector.app.features.userdirectory.UserListFragmentArgs import im.vector.app.features.userdirectory.UserListFragmentArgs
import im.vector.app.features.userdirectory.UserListSharedAction import im.vector.app.features.userdirectory.UserListSharedAction
@ -59,6 +64,8 @@ import javax.inject.Inject
class CreateDirectRoomActivity : SimpleFragmentActivity() { class CreateDirectRoomActivity : SimpleFragmentActivity() {
private val viewModel: CreateDirectRoomViewModel by viewModel() private val viewModel: CreateDirectRoomViewModel by viewModel()
private val qrViewModel: QrCodeScannerViewModel by viewModel()
private lateinit var sharedActionViewModel: UserListSharedActionViewModel private lateinit var sharedActionViewModel: UserListSharedActionViewModel
@Inject lateinit var errorFormatter: ErrorFormatter @Inject lateinit var errorFormatter: ErrorFormatter
@ -93,11 +100,38 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
viewModel.onEach(CreateDirectRoomViewState::createAndInviteState) { viewModel.onEach(CreateDirectRoomViewState::createAndInviteState) {
renderCreateAndInviteState(it) renderCreateAndInviteState(it)
} }
viewModel.observeViewEvents {
when (it) {
CreateDirectRoomViewEvents.InvalidCode -> {
Toast.makeText(this, R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show()
finish()
}
CreateDirectRoomViewEvents.DmSelf -> {
Toast.makeText(this, R.string.cannot_dm_self, Toast.LENGTH_SHORT).show()
finish()
}
}.exhaustive
}
qrViewModel.observeViewEvents {
when (it) {
is QrCodeScannerEvents.CodeParsed -> {
viewModel.handle(CreateDirectRoomAction.QrScannedAction(it.result))
}
is QrCodeScannerEvents.ParseFailed -> {
Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show()
finish()
}
else -> Unit
}.exhaustive
}
} }
private fun openAddByQrCode() { private fun openAddByQrCode() {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, permissionCameraLauncher)) { if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, permissionCameraLauncher)) {
addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java) val args = QrScannerArgs(showExtraButtons = false, R.string.add_by_qr_code)
addFragment(views.container, QrCodeScannerFragment::class.java, args)
} }
} }
@ -118,7 +152,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
private val permissionCameraLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> private val permissionCameraLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) { if (allGranted) {
addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java) addFragment(views.container, QrCodeScannerFragment::class.java)
} else if (deniedPermanently) { } else if (deniedPermanently) {
onPermissionDeniedSnackbar(R.string.permissions_denied_qr_code) onPermissionDeniedSnackbar(R.string.permissions_denied_qr_code)
} }

View File

@ -1,138 +0,0 @@
/*
* Copyright 2020 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.app.features.createdirect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.airbnb.mvrx.activityViewModel
import com.google.zxing.Result
import com.google.zxing.ResultMetadataType
import im.vector.app.R
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentQrCodeScannerBinding
import im.vector.app.features.userdirectory.PendingSelection
import me.dm7.barcodescanner.zxing.ZXingScannerView
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.permalinks.PermalinkParser
import org.matrix.android.sdk.api.session.user.model.User
import javax.inject.Inject
class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragment<FragmentQrCodeScannerBinding>(), ZXingScannerView.ResultHandler {
private val viewModel: CreateDirectRoomViewModel by activityViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerBinding {
return FragmentQrCodeScannerBinding.inflate(inflater, container, false)
}
private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
startCamera()
} else if (deniedPermanently) {
activity?.onPermissionDeniedDialog(R.string.denied_permission_camera)
}
}
private fun startCamera() {
// Start camera on resume
views.scannerView.startCamera()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar(views.qrScannerToolbar)
.setTitle(R.string.add_by_qr_code)
.allowBack(useCross = true)
}
override fun onResume() {
super.onResume()
view?.hideKeyboard()
// Register ourselves as a handler for scan results.
views.scannerView.setResultHandler(this)
// Start camera on resume
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) {
startCamera()
}
}
override fun onPause() {
super.onPause()
// Unregister ourselves as a handler for scan results.
views.scannerView.setResultHandler(null)
// Stop camera on pause
views.scannerView.stopCamera()
}
// Copied from https://github.com/markusfisch/BinaryEye/blob/
// 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434
private fun getRawBytes(result: Result): ByteArray? {
val metadata = result.resultMetadata ?: return null
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null
var bytes = ByteArray(0)
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
bytes += seg
}
// byte segments can never be shorter than the text.
// Zxing cuts off content prefixes like "WIFI:"
return if (bytes.size >= result.text.length) bytes else null
}
private fun addByQrCode(value: String) {
val mxid = (PermalinkParser.parse(value) as? PermalinkData.UserLink)?.userId
if (mxid === null) {
Toast.makeText(requireContext(), R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show()
requireActivity().finish()
} else {
// The following assumes MXIDs are case insensitive
if (mxid.equals(other = viewModel.session.myUserId, ignoreCase = true)) {
Toast.makeText(requireContext(), R.string.cannot_dm_self, Toast.LENGTH_SHORT).show()
requireActivity().finish()
} else {
// Try to get user from known users and fall back to creating a User object from MXID
val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null)
viewModel.handle(
CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingSelection.UserPendingSelection(qrInvitee)))
)
}
}
}
override fun handleResult(result: Result?) {
if (result === null) {
Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show()
requireActivity().finish()
} else {
val rawBytes = getRawBytes(result)
val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1)
val value = rawBytesStr ?: result.text
addByQrCode(value)
}
}
}

View File

@ -18,4 +18,7 @@ package im.vector.app.features.createdirect
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
sealed class CreateDirectRoomViewEvents : VectorViewEvents sealed class CreateDirectRoomViewEvents : VectorViewEvents {
object InvalidCode : CreateDirectRoomViewEvents()
object DmSelf : CreateDirectRoomViewEvents()
}

View File

@ -34,7 +34,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.permalinks.PermalinkParser
import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams
import org.matrix.android.sdk.api.session.user.model.User
class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
initialState: CreateDirectRoomViewState, initialState: CreateDirectRoomViewState,
@ -51,15 +54,33 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
override fun handle(action: CreateDirectRoomAction) { override fun handle(action: CreateDirectRoomAction) {
when (action) { when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action) is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections)
is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action)
}.exhaustive }.exhaustive
} }
private fun onCodeParsed(action: CreateDirectRoomAction.QrScannedAction) {
val mxid = (PermalinkParser.parse(action.result) as? PermalinkData.UserLink)?.userId
if (mxid === null) {
_viewEvents.post(CreateDirectRoomViewEvents.InvalidCode)
} else {
// The following assumes MXIDs are case insensitive
if (mxid.equals(other = session.myUserId, ignoreCase = true)) {
_viewEvents.post(CreateDirectRoomViewEvents.DmSelf)
} else {
// Try to get user from known users and fall back to creating a User object from MXID
val qrInvitee = if (session.getUser(mxid) != null) session.getUser(mxid)!! else User(mxid, null, null)
onSubmitInvitees(setOf(PendingSelection.UserPendingSelection(qrInvitee)))
}
}
}
/** /**
* If users already have a DM room then navigate to it instead of creating a new room. * If users already have a DM room then navigate to it instead of creating a new room.
*/ */
private fun onSubmitInvitees(action: CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers) { private fun onSubmitInvitees(selections: Set<PendingSelection>) {
val existingRoomId = action.selections.singleOrNull()?.getMxId()?.let { userId -> val existingRoomId = selections.singleOrNull()?.getMxId()?.let { userId ->
session.getExistingDirectRoomWithUser(userId) session.getExistingDirectRoomWithUser(userId)
} }
if (existingRoomId != null) { if (existingRoomId != null) {
@ -69,7 +90,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
} }
} else { } else {
// Create the DM // Create the DM
createRoomAndInviteSelectedUsers(action.selections) createRoomAndInviteSelectedUsers(selections)
} }
} }

View File

@ -21,7 +21,7 @@ import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.features.displayname.getBestName import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailActivity
import im.vector.app.features.home.room.detail.RoomDetailArgs import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.PopupAlertManager
import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.popup.VerificationVectorAlert
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -142,7 +142,7 @@ class IncomingVerificationRequestHandler @Inject constructor(
R.drawable.ic_shield_black, R.drawable.ic_shield_black,
shouldBeDisplayedIn = { activity -> shouldBeDisplayedIn = { activity ->
if (activity is RoomDetailActivity) { if (activity is RoomDetailActivity) {
activity.intent?.extras?.getParcelable<RoomDetailArgs>(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let { activity.intent?.extras?.getParcelable<TimelineArgs>(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let {
it.roomId != pr.roomId it.roomId != pr.roomId
} ?: true } ?: true
} else true } else true

View File

@ -550,7 +550,7 @@ class HomeActivity :
return true return true
} }
override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean {
if (roomId == null) return false if (roomId == null) return false
MatrixToBottomSheet.withLink(deepLink.toString()) MatrixToBottomSheet.withLink(deepLink.toString())
.show(supportFragmentManager, "HA#MatrixToBottomSheet") .show(supportFragmentManager, "HA#MatrixToBottomSheet")

View File

@ -16,7 +16,6 @@
package im.vector.app.features.home package im.vector.app.features.home
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -25,6 +24,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorDummyViewState
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -37,22 +37,18 @@ import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.unwrap import org.matrix.android.sdk.flow.unwrap
import timber.log.Timber import timber.log.Timber
data class DummyState(
val dummy: Boolean = false
) : MavericksState
class UserColorAccountDataViewModel @AssistedInject constructor( class UserColorAccountDataViewModel @AssistedInject constructor(
@Assisted initialState: DummyState, @Assisted initialState: VectorDummyViewState,
private val session: Session, private val session: Session,
private val matrixItemColorProvider: MatrixItemColorProvider private val matrixItemColorProvider: MatrixItemColorProvider
) : VectorViewModel<DummyState, EmptyAction, EmptyViewEvents>(initialState) { ) : VectorViewModel<VectorDummyViewState, EmptyAction, EmptyViewEvents>(initialState) {
@AssistedFactory @AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<UserColorAccountDataViewModel, DummyState> { interface Factory : MavericksAssistedViewModelFactory<UserColorAccountDataViewModel, VectorDummyViewState> {
override fun create(initialState: DummyState): UserColorAccountDataViewModel override fun create(initialState: VectorDummyViewState): UserColorAccountDataViewModel
} }
companion object : MavericksViewModelFactory<UserColorAccountDataViewModel, DummyState> by hiltMavericksViewModelFactory() companion object : MavericksViewModelFactory<UserColorAccountDataViewModel, VectorDummyViewState> by hiltMavericksViewModelFactory()
init { init {
observeAccountData() observeAccountData()

View File

@ -49,9 +49,10 @@ import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem
class AutoCompleter @AssistedInject constructor( class AutoCompleter @AssistedInject constructor(
@Assisted val roomId: String, @Assisted val roomId: String,
@Assisted val isInThreadTimeline: Boolean,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val commandAutocompletePolicy: CommandAutocompletePolicy, private val commandAutocompletePolicy: CommandAutocompletePolicy,
private val autocompleteCommandPresenter: AutocompleteCommandPresenter, AutocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory,
private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory, private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory,
private val autocompleteRoomPresenter: AutocompleteRoomPresenter, private val autocompleteRoomPresenter: AutocompleteRoomPresenter,
private val autocompleteGroupPresenter: AutocompleteGroupPresenter, private val autocompleteGroupPresenter: AutocompleteGroupPresenter,
@ -62,7 +63,11 @@ class AutoCompleter @AssistedInject constructor(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(roomId: String): AutoCompleter fun create(roomId: String, isInThreadTimeline: Boolean): AutoCompleter
}
private val autocompleteCommandPresenter: AutocompleteCommandPresenter by lazy {
AutocompleteCommandPresenterFactory.create(isInThreadTimeline)
} }
private var editText: EditText? = null private var editText: EditText? = null

View File

@ -44,7 +44,7 @@ class JoinReplacementRoomBottomSheet :
@Inject @Inject
lateinit var errorFormatter: ErrorFormatter lateinit var errorFormatter: ErrorFormatter
private val viewModel: RoomDetailViewModel by parentFragmentViewModel() private val viewModel: TimelineViewModel by parentFragmentViewModel()
override val showExpanded: Boolean override val showExpanded: Boolean
get() = true get() = true

View File

@ -38,6 +38,7 @@ import im.vector.app.databinding.ActivityRoomDetailBinding
import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.analytics.plan.Screen
import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.analytics.screen.ScreenEvent
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment
import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.matrixto.MatrixToBottomSheet
import im.vector.app.features.navigation.Navigator import im.vector.app.features.navigation.Navigator
@ -97,17 +98,17 @@ class RoomDetailActivity :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false)
waitingView = views.waitingView.waitingView waitingView = views.waitingView.waitingView
val roomDetailArgs: RoomDetailArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) { val timelineArgs: TimelineArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) {
RoomDetailArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!) TimelineArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!)
} else { } else {
intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS) intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
} }
if (roomDetailArgs == null) return if (timelineArgs == null) return
intent.putExtra(Mavericks.KEY_ARG, roomDetailArgs) intent.putExtra(Mavericks.KEY_ARG, timelineArgs)
currentRoomId = roomDetailArgs.roomId currentRoomId = timelineArgs.roomId
if (isFirstCreation()) { if (isFirstCreation()) {
replaceFragment(views.roomDetailContainer, RoomDetailFragment::class.java, roomDetailArgs) replaceFragment(views.roomDetailContainer, TimelineFragment::class.java, timelineArgs)
replaceFragment(views.roomDetailDrawerContainer, BreadcrumbsFragment::class.java) replaceFragment(views.roomDetailDrawerContainer, BreadcrumbsFragment::class.java)
} }
@ -145,7 +146,7 @@ class RoomDetailActivity :
if (currentRoomId != switchToRoom.roomId) { if (currentRoomId != switchToRoom.roomId) {
currentRoomId = switchToRoom.roomId currentRoomId = switchToRoom.roomId
requireActiveMembershipViewModel.handle(RequireActiveMembershipAction.ChangeRoom(switchToRoom.roomId)) requireActiveMembershipViewModel.handle(RequireActiveMembershipAction.ChangeRoom(switchToRoom.roomId))
replaceFragment(views.roomDetailContainer, RoomDetailFragment::class.java, RoomDetailArgs(switchToRoom.roomId)) replaceFragment(views.roomDetailContainer, TimelineFragment::class.java, TimelineArgs(switchToRoom.roomId))
} }
} }
@ -196,9 +197,9 @@ class RoomDetailActivity :
const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID" const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID"
const val ACTION_ROOM_DETAILS_FROM_SHORTCUT = "ROOM_DETAILS_FROM_SHORTCUT" const val ACTION_ROOM_DETAILS_FROM_SHORTCUT = "ROOM_DETAILS_FROM_SHORTCUT"
fun newIntent(context: Context, roomDetailArgs: RoomDetailArgs): Intent { fun newIntent(context: Context, timelineArgs: TimelineArgs): Intent {
return Intent(context, RoomDetailActivity::class.java).apply { return Intent(context, RoomDetailActivity::class.java).apply {
putExtra(EXTRA_ROOM_DETAIL_ARGS, roomDetailArgs) putExtra(EXTRA_ROOM_DETAIL_ARGS, timelineArgs)
} }
} }

View File

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail
import com.airbnb.mvrx.Async import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.initsync.SyncStatusService import org.matrix.android.sdk.api.session.initsync.SyncStatusService
@ -26,6 +27,7 @@ import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.api.session.threads.ThreadNotificationBadgeState
import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.session.widgets.model.WidgetType
@ -67,15 +69,18 @@ data class RoomDetailViewState(
val isAllowedToSetupEncryption: Boolean = true, val isAllowedToSetupEncryption: Boolean = true,
val hasFailedSending: Boolean = false, val hasFailedSending: Boolean = false,
val jitsiState: JitsiState = JitsiState(), val jitsiState: JitsiState = JitsiState(),
val switchToParentSpace: Boolean = false val switchToParentSpace: Boolean = false,
val rootThreadEventId: String? = null,
val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState()
) : MavericksState { ) : MavericksState {
constructor(args: RoomDetailArgs) : this( constructor(args: TimelineArgs) : this(
roomId = args.roomId, roomId = args.roomId,
eventId = args.eventId, eventId = args.eventId,
// Also highlight the target event, if any // Also highlight the target event, if any
highlightedEventId = args.eventId, highlightedEventId = args.eventId,
switchToParentSpace = args.switchToParentSpace switchToParentSpace = args.switchToParentSpace,
rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId
) )
fun isWebRTCCallOptionAvailable() = (asyncRoomSummary.invoke()?.joinedMembersCount ?: 0) <= 2 fun isWebRTCCallOptionAvailable() = (asyncRoomSummary.invoke()?.joinedMembersCount ?: 0) <= 2
@ -85,4 +90,6 @@ data class RoomDetailViewState(
fun hasActiveJitsiWidget() = activeRoomWidgets()?.any { it.type == WidgetType.Jitsi && it.isActive }.orFalse() fun hasActiveJitsiWidget() = activeRoomWidgets()?.any { it.type == WidgetType.Jitsi && it.isActive }.orFalse()
fun isDm() = asyncRoomSummary()?.isDirect == true fun isDm() = asyncRoomSummary()?.isDirect == true
fun isThreadTimeline() = rootThreadEventId != null
} }

View File

@ -32,7 +32,7 @@ class StartCallActionsHandler(
private val fragment: Fragment, private val fragment: Fragment,
private val callManager: WebRtcCallManager, private val callManager: WebRtcCallManager,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val roomDetailViewModel: RoomDetailViewModel, private val timelineViewModel: TimelineViewModel,
private val startCallActivityResultLauncher: ActivityResultLauncher<Array<String>>, private val startCallActivityResultLauncher: ActivityResultLauncher<Array<String>>,
private val showDialogWithMessage: (String) -> Unit, private val showDialogWithMessage: (String) -> Unit,
private val onTapToReturnToCall: () -> Unit) { private val onTapToReturnToCall: () -> Unit) {
@ -45,7 +45,7 @@ class StartCallActionsHandler(
handleCallRequest(false) handleCallRequest(false)
} }
private fun handleCallRequest(isVideoCall: Boolean) = withState(roomDetailViewModel) { state -> private fun handleCallRequest(isVideoCall: Boolean) = withState(timelineViewModel) { state ->
val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState
when (roomSummary.joinedMembersCount) { when (roomSummary.joinedMembersCount) {
1 -> { 1 -> {
@ -95,7 +95,7 @@ class StartCallActionsHandler(
.setMessage(R.string.audio_video_meeting_description) .setMessage(R.string.audio_video_meeting_description)
.setPositiveButton(fragment.getString(R.string.create)) { _, _ -> .setPositiveButton(fragment.getString(R.string.create)) { _, _ ->
// create the widget, then navigate to it.. // create the widget, then navigate to it..
roomDetailViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall)) timelineViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall))
} }
.setNegativeButton(fragment.getString(R.string.action_cancel), null) .setNegativeButton(fragment.getString(R.string.action_cancel), null)
.show() .show()
@ -121,22 +121,22 @@ class StartCallActionsHandler(
private fun safeStartCall2(isVideoCall: Boolean) { private fun safeStartCall2(isVideoCall: Boolean) {
val startCallAction = RoomDetailAction.StartCall(isVideoCall) val startCallAction = RoomDetailAction.StartCall(isVideoCall)
roomDetailViewModel.pendingAction = startCallAction timelineViewModel.pendingAction = startCallAction
if (isVideoCall) { if (isVideoCall) {
if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL,
fragment.requireActivity(), fragment.requireActivity(),
startCallActivityResultLauncher, startCallActivityResultLauncher,
R.string.permissions_rationale_msg_camera_and_audio)) { R.string.permissions_rationale_msg_camera_and_audio)) {
roomDetailViewModel.pendingAction = null timelineViewModel.pendingAction = null
roomDetailViewModel.handle(startCallAction) timelineViewModel.handle(startCallAction)
} }
} else { } else {
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL,
fragment.requireActivity(), fragment.requireActivity(),
startCallActivityResultLauncher, startCallActivityResultLauncher,
R.string.permissions_rationale_msg_record_audio)) { R.string.permissions_rationale_msg_record_audio)) {
roomDetailViewModel.pendingAction = null timelineViewModel.pendingAction = null
roomDetailViewModel.handle(startCallAction) timelineViewModel.handle(startCallAction)
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More