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@")"
echo "::set-output name=permalink::passed=$body"
- name: Find Comment
if: github.event_name == 'pull_request'
uses: peter-evans/find-comment@v1
id: fc
with:
@ -187,6 +188,7 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: Integration Tests Results
- name: Publish results to PR
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v1
with:
comment-id: ${{ steps.fc.outputs.comment-id }}

View file

@ -54,7 +54,7 @@ jobs:
echo "::set-output name=body::$body"
fi
- name: Find Comment
if: always()
if: always() && github.event_name == 'pull_request'
uses: peter-evans/find-comment@v1
id: fc
with:
@ -62,7 +62,7 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: Ktlint Results
- 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
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
@ -73,7 +73,7 @@ jobs:
${{ steps.ktlint-results.outputs.body }}
edit-mode: replace
- 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
with:
script: |

View file

@ -144,6 +144,11 @@ project(":diff-match-patch") {
}
}
// Global configurations across all modules
ext {
isThreadingEnabled = true
}
//project(":matrix-sdk-android") {
// sonarqube {
// 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,
]
def gradle = "7.0.4"
def gradle = "7.1.0"
// Ref: https://kotlinlang.org/releases.html
def kotlin = "1.5.31"
def kotlinCoroutines = "1.5.2"
@ -37,7 +37,6 @@ ext.libs = [
'gradlePlugin' : "com.android.tools.build:gradle:$gradle",
'kotlinPlugin' : "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin",
'hiltPlugin' : "com.google.dagger:hilt-android-gradle-plugin:$dagger"
],
jetbrains : [
'coroutinesCore' : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutines",

View file

@ -175,6 +175,7 @@ ext.groups = [
'org.sonatype.oss',
'org.testng',
'org.threeten',
'org.webjars',
'ru.noties',
'xerces',
'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 -->
<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 -->
<dimen name="composer_min_height">56dp</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.toOptional
typealias ThreadRootEvent = TimelineEvent
class FlowRoom(private val room: Room) {
fun liveRoomSummary(): Flow<Optional<RoomSummary>> {
@ -98,6 +100,20 @@ class FlowRoom(private val room: Room) {
fun liveNotificationState(): Flow<RoomNotificationState> {
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 {

View file

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

View file

@ -157,14 +157,20 @@ class CommonTestHelper(context: Context) {
/**
* 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)
(1 until count + 1)
.map { "$message #$it" }
.chunked(10)
.forEach { batchedMessages ->
batchedMessages.forEach { formattedMessage ->
room.sendTextMessage(formattedMessage)
if (rootThreadEventId != null) {
room.replyInThread(
rootThreadEventId = rootThreadEventId,
replyInThreadText = formattedMessage)
} else {
room.sendTextMessage(formattedMessage)
}
}
waitWithLatch(timeout) { latch ->
val timelineListener = object : Timeline.Listener {
@ -196,6 +202,27 @@ class CommonTestHelper(context: Context) {
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 *****************************************************************************
/**

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.RoomMemberContent
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.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.threads.ThreadDetails
import org.matrix.android.sdk.api.util.ContentUtils
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.model.event.EncryptedEventContent
@ -98,6 +103,9 @@ data class Event(
@Transient
var sendStateDetails: String? = null
@Transient
var threadDetails: ThreadDetails? = null
fun sendStateError(): MatrixError? {
return sendStateDetails?.let {
val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
@ -123,6 +131,7 @@ data class Event(
it.mCryptoErrorReason = mCryptoErrorReason
it.sendState = sendState
it.ageLocalTs = ageLocalTs
it.threadDetails = threadDetails
}
}
@ -185,6 +194,51 @@ data class Event(
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
*/
@ -217,7 +271,7 @@ data class Event(
if (mCryptoError != other.mCryptoError) return false
if (mCryptoErrorReason != other.mCryptoErrorReason) return false
if (sendState != other.sendState) return false
if (threadDetails != other.threadDetails) return false
return true
}
@ -236,6 +290,8 @@ data class Event(
result = 31 * result + (mCryptoError?.hashCode() ?: 0)
result = 31 * result + (mCryptoErrorReason?.hashCode() ?: 0)
result = 31 * result + sendState.hashCode()
result = 31 * result + threadDetails.hashCode()
return result
}
}
@ -243,70 +299,101 @@ data class Event(
fun Event.isTextMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_NOTICE -> true
else -> false
}
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_NOTICE -> true
else -> false
}
}
fun Event.isImageMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_IMAGE -> true
else -> false
}
MessageType.MSGTYPE_IMAGE -> true
else -> false
}
}
fun Event.isVideoMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_VIDEO -> true
else -> false
}
MessageType.MSGTYPE_VIDEO -> true
else -> false
}
}
fun Event.isAudioMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_AUDIO -> true
else -> false
}
MessageType.MSGTYPE_AUDIO -> true
else -> false
}
}
fun Event.isFileMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_FILE -> true
else -> false
}
MessageType.MSGTYPE_FILE -> true
else -> false
}
}
fun Event.isAttachmentMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
}
fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END
fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
fun Event.getRelationContent(): RelationDefaultContent? {
return if (isEncrypted()) {
content.toModel<EncryptedEventContent>()?.relatesTo
} 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 {
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 {
return getRelationContent()?.takeIf { it.type == RelationType.REPLACE }?.eventId != null
return getRelationContentForType(RelationType.REPLACE)?.eventId != null
}
fun Event.getPresenceContent(): PresenceContent? {
@ -315,3 +402,7 @@ fun Event.getPresenceContent(): PresenceContent? {
fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
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.*/
const val REFERENCE = "m.reference"
/** Lets you define an thread event that belongs to another existing event.*/
// const val THREAD = "m.thread" // m.thread is not yet released in the backend
const val THREAD = "io.element.thread" // io.element.thread will be replaced by m.thread when it is released
/** Lets you define an event which is a thread reply to an existing event.*/
const val THREAD = "m.thread"
const val IO_THREAD = "io.element.thread"
/** Lets you define an event which adds a response to an existing event.*/
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.state.StateService
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.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.util.Optional
*/
interface Room :
TimelineService,
ThreadsService,
SendService,
DraftService,
ReadService,

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.relation
import androidx.lifecycle.LiveData
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.message.MessageType
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.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.
* 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).
*
* 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 {
@ -118,10 +122,15 @@ interface RelationService {
* @param eventReplied the event referenced by the reply
* @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 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,
replyText: CharSequence,
autoMarkdown: Boolean = false): Cancelable?
autoMarkdown: Boolean = false,
showInThread: Boolean = false,
rootThreadEventId: String? = null
): Cancelable?
/**
* Get the current EventAnnotationsSummary
@ -136,4 +145,31 @@ interface RelationService {
* @return the LiveData of 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)
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
* @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.
@ -72,11 +72,13 @@ interface SendService {
* @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.
* 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]
*/
fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable
roomIds: Set<String>,
rootThreadEventId: String? = null): Cancelable
/**
* 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 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
* @param rootThreadEventId when this param is not null, all the Media will be sent in this specific thread
* @return a [Cancelable]
*/
fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable
roomIds: Set<String>,
rootThreadEventId: String? = null): Cancelable
/**
* 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
*/
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.

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.getRelationContent
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.isSticker
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.ReadReceipt
@ -149,6 +151,13 @@ fun TimelineEvent.isEdition(): Boolean {
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
*/

View file

@ -27,5 +27,14 @@ data class TimelineSettings(
/**
* 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
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
}

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.create.RoomCreateContent
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.database.model.ChunkEntityFields
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields
@ -56,7 +57,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
) : RealmMigration {
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 <= 19) migrateTo20(realm)
if (oldVersion <= 20) migrateTo21(realm)
if (oldVersion <= 21) migrateTo22(realm)
}
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.getOrCreate
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.session.room.timeline.PaginationDirection
import timber.log.Timber
@ -81,7 +82,7 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity,
internal fun ChunkEntity.addTimelineEvent(roomId: String,
eventEntity: EventEntity,
direction: PaginationDirection,
roomMemberContentsByUser: Map<String, RoomMemberContent?>) {
roomMemberContentsByUser: Map<String, RoomMemberContent?>? = null) {
val eventId = eventEntity.eventId
if (timelineEvents.find(eventId) != null) {
return
@ -101,7 +102,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String,
?.also { it.cleanUp(eventEntity.sender) }
this.readReceipts = readReceiptsSummaryEntity
this.displayIndex = displayIndex
val roomMemberContent = roomMemberContentsByUser[senderId]
val roomMemberContent = roomMemberContentsByUser?.get(senderId)
this.senderAvatar = roomMemberContent?.avatarUrl
this.senderName = roomMemberContent?.displayName
isUniqueDisplayName = if (roomMemberContent?.displayName != null) {
@ -157,9 +158,21 @@ private fun ChunkEntity.addTimelineEventFromMerge(realm: Realm, timelineEventEnt
this.senderName = timelineEventEntity.senderName
this.isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName
}
handleThreadSummary(realm, eventId, 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 {
val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventEntity.eventId).findFirst()
?: 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.EventType
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.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.database.model.EventEntity
import org.matrix.android.sdk.internal.di.MoshiProvider
@ -51,6 +55,10 @@ internal object EventMapper {
}
eventEntity.decryptionErrorReason = event.mCryptoErrorReason
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
}
@ -93,6 +101,23 @@ internal object EventMapper {
MXCryptoError.ErrorType.valueOf(errorCode)
}
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)
}
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 {
this.sendState = sendState
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.annotations.Index
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.algorithms.olm.OlmDecryptionResult
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 redacts: 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() {
private var sendStateStr: String = SendState.UNKNOWN.name
@ -53,6 +58,15 @@ internal open class EventEntity(@Index var eventId: String = "",
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
set(value) {
if (value != field) field = value
@ -65,10 +79,10 @@ internal open class EventEntity(@Index var eventId: String = "",
companion object
fun setDecryptionResult(result: MXEventDecryptionResult, clearEvent: JsonDict? = null) {
fun setDecryptionResult(result: MXEventDecryptionResult) {
assertIsManaged()
val decryptionResult = OlmDecryptionResult(
payload = clearEvent ?: result.clearEvent,
payload = result.clearEvent,
senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
@ -84,4 +98,6 @@ internal open class EventEntity(@Index var eventId: String = "",
.findFirst()
?.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)
}
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> {
return realm.where<EventEntity>()
.`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 {
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? {
val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: return null
val sendingTimelineEvents = roomEntity.sendingTimelineEvents.where().filterEvents(filters)
val liveEvents = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)?.timelineEvents?.where()?.filterEvents(filters)
val query = if (includesSending && sendingTimelineEvents.findAll().isNotEmpty()) {
sendingTimelineEvents
@ -100,6 +101,7 @@ internal fun RealmQuery<TimelineEventEntity>.filterEvents(filters: TimelineEvent
if (filters.filterRedacted) {
not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED)
}
return this
}

View file

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

View file

@ -48,6 +48,16 @@ data class RoomEventFilter(
* a wildcard to match any sequence of characters.
*/
@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.
*/

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.state.StateService
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.typing.TypingService
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,
private val roomSummaryDataSource: RoomSummaryDataSource,
private val timelineService: TimelineService,
private val threadsService: ThreadsService,
private val sendService: SendService,
private val draftService: DraftService,
private val stateService: StateService,
@ -77,6 +79,7 @@ internal class DefaultRoom(override val roomId: String,
) :
Room,
TimelineService by timelineService,
ThreadsService by threadsService,
SendService by sendService,
DraftService by draftService,
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.crypto.model.event.EncryptedEventContent
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.EventMapper
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,

View file

@ -226,7 +226,8 @@ internal interface RoomAPI {
suspend fun getRelations(@Path("roomId") roomId: String,
@Path("eventId") eventId: String,
@Path("relationType") relationType: String,
@Path("eventType") eventType: String
@Path("eventType") eventType: String,
@Query("limit") limit: Int? = null
): 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.summary.RoomSummaryDataSource
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.typing.DefaultTypingService
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,
private val roomSummaryDataSource: RoomSummaryDataSource,
private val timelineServiceFactory: DefaultTimelineService.Factory,
private val threadsServiceFactory: DefaultThreadsService.Factory,
private val sendServiceFactory: DefaultSendService.Factory,
private val draftServiceFactory: DefaultDraftService.Factory,
private val stateServiceFactory: DefaultStateService.Factory,
@ -76,6 +78,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService:
roomId = roomId,
roomSummaryDataSource = roomSummaryDataSource,
timelineService = timelineServiceFactory.create(roomId),
threadsService = threadsServiceFactory.create(roomId),
sendService = sendServiceFactory.create(roomId),
draftService = draftServiceFactory.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.FindReactionEventForUndoTask
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.ReportContentTask
import org.matrix.android.sdk.internal.session.room.state.DefaultSendStateTask
@ -289,4 +291,7 @@ internal abstract class RoomModule {
@Binds
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)
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.decryptionResultJson = 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.toOptional
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.asDomain
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.query.where
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.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.task.TaskExecutor
@ -51,12 +54,15 @@ internal class DefaultRelationService @AssistedInject constructor(
private val eventSenderProcessor: EventSenderProcessor,
private val eventFactory: LocalEchoEventFactory,
private val cryptoSessionInfoProvider: CryptoSessionInfoProvider,
private val cryptoService: DefaultCryptoService,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val fetchEditHistoryTask: FetchEditHistoryTask,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val timelineEventMapper: TimelineEventMapper,
@UserId private val userId: String,
@SessionDatabase private val monarchy: Monarchy,
private val taskExecutor: TaskExecutor) :
RelationService {
RelationService {
@AssistedFactory
interface Factory {
@ -139,8 +145,20 @@ internal class DefaultRelationService @AssistedInject constructor(
return fetchEditHistoryTask.execute(FetchEditHistoryTask.Params(roomId, eventId))
}
override fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Cancelable? {
val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown)
override fun replyToMessage(
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) }
?: 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.
* 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
if (replyToEdit.root.sendState.hasFailed()) {
// 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
) ?: return NoOpCancellable
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) }
}
override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable {
return localEchoEventFactory.createQuotedTextEvent(roomId, quotedEvent, text, autoMarkdown)
override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable {
return localEchoEventFactory.createQuotedTextEvent(
roomId = roomId,
quotedEvent = quotedEvent,
text = text,
autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId
)
.also { createLocalEcho(it) }
.let { sendEvent(it) }
}
@ -254,22 +260,37 @@ internal class DefaultSendService @AssistedInject constructor(
override fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable {
roomIds: Set<String>,
rootThreadEventId: String?
): Cancelable {
return attachments.mapTo(CancelableBag()) {
sendMedia(it, compressBeforeSending, roomIds)
sendMedia(
attachment = it,
compressBeforeSending = compressBeforeSending,
roomIds = roomIds,
rootThreadEventId = rootThreadEventId)
}
}
override fun sendMedia(attachment: ContentAttachmentData,
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
// Ensure current roomId is included in the set
val allRoomIds = (roomIds + roomId).toList()
// Create local echo for each room
val allLocalEchoes = allRoomIds.map {
localEchoEventFactory.createMediaEvent(it, attachment).also { event ->
localEchoEventFactory.createMediaEvent(
roomId = it,
attachment = attachment,
rootThreadEventId = rootThreadId).also { 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.UnsignedData
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.AudioWaveformInfo
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.MessagePollContent
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.MessageType
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) {
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment)
ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment)
ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false)
ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true)
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment)
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId)
ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId)
ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId)
ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true, rootThreadEventId = rootThreadEventId)
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId)
}
}
@ -321,7 +326,7 @@ internal class LocalEchoEventFactory @Inject constructor(
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 height = attachment.height
@ -345,12 +350,19 @@ internal class LocalEchoEventFactory @Inject constructor(
height = height?.toInt() ?: 0,
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)
}
private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event {
private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
val mediaDataRetriever = MediaMetadataRetriever()
mediaDataRetriever.setDataSource(context, attachment.queryUri)
@ -381,12 +393,23 @@ internal class LocalEchoEventFactory @Inject constructor(
thumbnailUrl = attachment.queryUri.toString(),
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)
}
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(
msgType = MessageType.MSGTYPE_AUDIO,
body = attachment.name ?: "audio",
@ -400,12 +423,19 @@ internal class LocalEchoEventFactory @Inject constructor(
duration = attachment.duration?.toInt(),
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)
}
private fun createFileEvent(roomId: String, attachment: ContentAttachmentData): Event {
private fun createFileEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event {
val content = MessageFileContent(
msgType = MessageType.MSGTYPE_FILE,
body = attachment.name ?: "file",
@ -413,7 +443,14 @@ internal class LocalEchoEventFactory @Inject constructor(
mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() },
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)
}
@ -423,6 +460,7 @@ internal class LocalEchoEventFactory @Inject constructor(
}
fun createEvent(roomId: String, type: String, content: Content?): Event {
val newContent = enhanceStickerIfNeeded(type, content) ?: content
val localId = LocalEcho.createLocalEchoId()
return Event(
roomId = roomId,
@ -430,19 +468,65 @@ internal class LocalEchoEventFactory @Inject constructor(
senderId = userId,
eventId = localId,
type = type,
content = content,
content = newContent,
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 {
return System.currentTimeMillis()
}
/**
* Creates a reply to a regular timeline Event or a thread Event if needed
*/
fun createReplyTextEvent(roomId: String,
eventReplied: TimelineEvent,
replyText: CharSequence,
autoMarkdown: Boolean): Event? {
autoMarkdown: Boolean,
rootThreadEventId: String? = null,
showInThread: Boolean): Event? {
// Fallbacks and event representation
// TODO Add error/warning logs when any of this is null
val permalink = permalinkFactory.createPermalink(eventReplied.root, false) ?: return null
@ -473,11 +557,33 @@ internal class LocalEchoEventFactory @Inject constructor(
format = MessageFormat.FORMAT_MATRIX_HTML,
body = replyFallback,
formattedBody = replyFormatted,
relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId))
)
relatesTo = generateReplyRelationContent(
eventId = eventId,
rootThreadEventId = rootThreadEventId,
showAsReply = showInThread))
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 {
return REPLY_PATTERN.format(
permalink,
@ -488,6 +594,7 @@ internal class LocalEchoEventFactory @Inject constructor(
newBodyFormatted
)
}
private fun buildReplyFallback(body: TextContent, originalSenderId: String?, newBodyText: String): String {
return buildString {
append("> <")
@ -593,11 +700,28 @@ internal class LocalEchoEventFactory @Inject constructor(
quotedEvent: TimelineEvent,
text: String,
autoMarkdown: Boolean,
rootThreadEventId: String?
): Event {
val messageContent = quotedEvent.getLastMessageContent()
val textMsg = messageContent?.body
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 {
@ -631,6 +755,7 @@ internal class LocalEchoEventFactory @Inject constructor(
// </mx-reply>
// 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 QUOTE_PATTERN = """<blockquote><p>%s</p></blockquote><p>%s</p>"""
// This is used to replace inner mx-reply tags
val MX_REPLY_REGEX = "<mx-reply>.*</mx-reply>".toRegex()

View file

@ -138,7 +138,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
}
}
fun deleteFailedEchoAsync(roomId: String, eventId: String?) {
fun deleteFailedEchoAsync(roomId: String, eventId: String?) {
monarchy.runTransactionSync { realm ->
TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId ?: "").findFirst()?.deleteFromRealm()
EventEntity.where(realm, eventId = eventId ?: "").findFirst()?.deleteFromRealm()
@ -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
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.MessageTextContent
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.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 {
return copy(
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.TimelineEvent
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.session.room.membership.LoadRoomMembersTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler
@ -60,6 +61,7 @@ internal class DefaultTimeline(private val roomId: String,
timelineEventMapper: TimelineEventMapper,
timelineInput: TimelineInput,
threadsAwarenessHandler: ThreadsAwarenessHandler,
lightweightSettingsStorage: LightweightSettingsStorage,
eventDecryptor: TimelineEventDecryptor) : Timeline {
companion object {
@ -79,6 +81,9 @@ internal class DefaultTimeline(private val roomId: String,
private val sequencer = SemaphoreCoroutineSequencer()
private val postSnapshotSignalFlow = MutableSharedFlow<Unit>(0)
private var isFromThreadTimeline = false
private var rootThreadEventId: String? = null
private val strategyDependencies = LoadTimelineStrategy.Dependencies(
timelineSettings = settings,
realm = backgroundRealm,
@ -89,6 +94,7 @@ internal class DefaultTimeline(private val roomId: String,
timelineInput = timelineInput,
timelineEventMapper = timelineEventMapper,
threadsAwarenessHandler = threadsAwarenessHandler,
lightweightSettingsStorage = lightweightSettingsStorage,
onEventsUpdated = this::sendSignalToPostSnapshot,
onLimitedTimeline = this::onLimitedTimeline,
onNewTimelineEvents = this::onNewTimelineEvents
@ -118,18 +124,21 @@ internal class DefaultTimeline(private val roomId: String,
listeners.clear()
}
override fun start() {
override fun start(rootThreadEventId: String?) {
timelineScope.launch {
loadRoomMembersIfNeeded()
}
timelineScope.launch {
sequencer.post {
if (isStarted.compareAndSet(false, true)) {
isFromThreadTimeline = rootThreadEventId != null
this@DefaultTimeline.rootThreadEventId = rootThreadEventId
// /
val realm = Realm.getInstance(realmConfiguration)
ensureReadReceiptAreLoaded(realm)
backgroundRealm.set(realm)
listenToPostSnapshotSignals()
openAround(initialEventId)
openAround(initialEventId, rootThreadEventId)
postSnapshot()
}
}
@ -150,7 +159,7 @@ internal class DefaultTimeline(private val roomId: String,
override fun restartWithEventId(eventId: String?) {
timelineScope.launch {
openAround(eventId)
openAround(eventId, rootThreadEventId)
postSnapshot()
}
}
@ -219,19 +228,24 @@ internal class DefaultTimeline(private val roomId: String,
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)"
Timber.v("$baseLogMessage started")
if (!isStarted.get()) {
throw IllegalStateException("You should call start before using timeline")
}
strategy.onStop()
strategy = if (eventId == null) {
buildStrategy(LoadTimelineStrategy.Mode.Live)
} else {
buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId))
strategy = when {
rootThreadEventId != null -> buildStrategy(LoadTimelineStrategy.Mode.Thread(rootThreadEventId))
eventId == null -> buildStrategy(LoadTimelineStrategy.Mode.Live)
else -> buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId))
}
initPaginationStates(eventId)
rootThreadEventId?.let {
initPaginationStates(null)
} ?: initPaginationStates(eventId)
strategy.onStart()
loadMore(
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.util.Optional
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.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
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.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.ThreadsAwarenessHandler
@ -44,6 +46,7 @@ import org.matrix.android.sdk.internal.task.TaskExecutor
internal class DefaultTimelineService @AssistedInject constructor(
@Assisted private val roomId: String,
@UserId private val userId: String,
@SessionDatabase private val monarchy: Monarchy,
private val realmSessionProvider: RealmSessionProvider,
private val timelineInput: TimelineInput,
@ -55,6 +58,7 @@ internal class DefaultTimelineService @AssistedInject constructor(
private val timelineEventMapper: TimelineEventMapper,
private val loadRoomMembersTask: LoadRoomMembersTask,
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val readReceiptHandler: ReadReceiptHandler,
private val coroutineDispatchers: MatrixCoroutineDispatchers
) : TimelineService {
@ -79,7 +83,8 @@ internal class DefaultTimelineService @AssistedInject constructor(
loadRoomMembersTask = loadRoomMembersTask,
readReceiptHandler = readReceiptHandler,
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.TimelineEvent
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.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
@ -51,6 +52,7 @@ internal class LoadTimelineStrategy(
sealed interface Mode {
object Live : Mode
data class Permalink(val originEventId: String) : Mode
data class Thread(val rootThreadEventId: String) : Mode
fun originEventId(): String? {
return if (this is Permalink) {
@ -59,6 +61,14 @@ internal class LoadTimelineStrategy(
null
}
}
// fun getRootThreadEventId(): String? {
// return if (this is Thread) {
// rootThreadEventId
// } else {
// null
// }
// }
}
data class Dependencies(
@ -71,6 +81,7 @@ internal class LoadTimelineStrategy(
val timelineInput: TimelineInput,
val timelineEventMapper: TimelineEventMapper,
val threadsAwarenessHandler: ThreadsAwarenessHandler,
val lightweightSettingsStorage: LightweightSettingsStorage,
val onEventsUpdated: (Boolean) -> Unit,
val onLimitedTimeline: () -> Unit,
val onNewTimelineEvents: (List<String>) -> Unit
@ -198,12 +209,20 @@ internal class LoadTimelineStrategy(
}
private fun getChunkEntity(realm: Realm): RealmResults<ChunkEntity> {
return if (mode is Mode.Permalink) {
ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId))
} else {
ChunkEntity.where(realm, roomId)
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findAll()
return when (mode) {
is Mode.Live -> {
ChunkEntity.where(realm, roomId)
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findAll()
}
is Mode.Permalink -> {
ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId))
}
is Mode.Thread -> {
ChunkEntity.where(realm, roomId)
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findAll()
}
}
}
@ -224,6 +243,7 @@ internal class LoadTimelineStrategy(
timelineEventMapper = dependencies.timelineEventMapper,
uiEchoManager = uiEchoManager,
threadsAwarenessHandler = dependencies.threadsAwarenessHandler,
lightweightSettingsStorage = dependencies.lightweightSettingsStorage,
initialEventId = mode.originEventId(),
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.TimelineEvent
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.TimelineEventMapper
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 uiEchoManager: UIEchoManager? = null,
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val initialEventId: String?,
private val onBuiltEvents: (Boolean) -> Unit) {
@ -92,7 +94,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
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 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) {
return prevChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE
}
val loadFromStorageCount = loadFromStorage(count, direction)
Timber.v("Has loaded $loadFromStorageCount items from storage in $direction")
val offsetCount = count - loadFromStorageCount
val loadFromStorage = loadFromStorage(count, direction).also {
logLoadedFromStorage(it, direction)
}
val offsetCount = count - loadFromStorage.numberOfEvents
return if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) {
LoadMoreResult.REACHED_END
} else if (direction == Timeline.Direction.BACKWARDS && isLastBackward.get()) {
LoadMoreResult.REACHED_END
} else if (timelineSettings.isThreadTimeline() && loadFromStorage.threadReachedEnd) {
LoadMoreResult.REACHED_END
} else if (offsetCount == 0) {
LoadMoreResult.SUCCESS
} 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? {
val builtEventIndex = builtEventsIndexes[eventId]
if (builtEventIndex != null) {
@ -267,13 +284,23 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
/**
* 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 {
val displayIndex = getNextDisplayIndex(direction) ?: return 0
private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): LoadedFromStorage {
val displayIndex = getNextDisplayIndex(direction) ?: return LoadedFromStorage()
val baseQuery = timelineEventEntities.where()
val timelineEvents = baseQuery.offsets(direction, count, displayIndex).findAll().orEmpty()
if (timelineEvents.isEmpty()) return 0
fetchRootThreadEventsIfNeeded(timelineEvents)
val timelineEvents = baseQuery
.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) {
builtEventsIndexes.entries.forEach { it.setValue(it.value + timelineEvents.size) }
}
@ -291,9 +318,20 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
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
* 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.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
}
@ -343,7 +385,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
val loadMoreResult = try {
if (token == null) {
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)
fetchTokenAndPaginateTask.execute(taskParams).toLoadMoreResult()
} else {
@ -352,7 +395,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
paginationTask.execute(taskParams).toLoadMoreResult()
}
} catch (failure: Throwable) {
Timber.e("Failed to fetch from server: $failure", failure)
Timber.e(failure, "Failed to fetch from server")
LoadMoreResult.FAILURE
}
return if (loadMoreResult == LoadMoreResult.SUCCESS) {
@ -450,10 +493,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
timelineEventMapper = timelineEventMapper,
uiEchoManager = uiEchoManager,
threadsAwarenessHandler = threadsAwarenessHandler,
lightweightSettingsStorage = lightweightSettingsStorage,
initialEventId = null,
onBuiltEvents = this.onBuiltEvents
)
}
private data class LoadedFromStorage(
val threadReachedEnd: Boolean = false,
val numberOfEvents: Int = 0
)
}
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
}
private fun ChunkEntity.sortedTimelineEvents(): RealmResults<TimelineEventEntity> {
return timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmResults<TimelineEventEntity> {
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.internal.crypto.NewSessionListener
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.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
@ -36,7 +38,8 @@ internal class TimelineEventDecryptor @Inject constructor(
@SessionDatabase
private val realmConfiguration: RealmConfiguration,
private val cryptoService: CryptoService,
private val threadsAwarenessHandler: ThreadsAwarenessHandler
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val lightweightSettingsStorage: LightweightSettingsStorage
) {
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) {
val event = request.event
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 {
val result = cryptoService.decryptEvent(request.event, timelineId)
Timber.v("Successfully decrypted event ${event.eventId}")
@ -112,15 +133,9 @@ internal class TimelineEventDecryptor @Inject constructor(
val eventEntity = EventEntity
.where(it, eventId = eventId)
.findFirst()
eventEntity?.apply {
val decryptedPayload = threadsAwarenessHandler.handleIfNeededDuringDecryption(
it,
roomId = event.roomId,
event,
result)
setDecryptionResult(result, decryptedPayload)
}
eventEntity?.setDecryptionResult(result)
val decryptedEvent = eventEntity?.asDomain()
threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity)
}
} catch (e: MXCryptoError) {
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.addStateEvent
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.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.RoomEntity
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.where
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.util.awaitTransaction
import timber.log.Timber
@ -45,8 +49,10 @@ import javax.inject.Inject
* Insert Chunk in DB, and eventually link next and previous chunk in db.
*/
internal class TokenChunkEventPersistor @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
private val liveEventManager: Lazy<StreamEventsManager>) {
@SessionDatabase private val monarchy: Monarchy,
@UserId private val userId: String,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val liveEventManager: Lazy<StreamEventsManager>) {
enum class Result {
SHOULD_FETCH_MORE,
@ -90,6 +96,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
handlePagination(realm, roomId, direction, receivedChunk, currentChunk)
}
}
return if (receivedChunk.events.isEmpty()) {
if (receivedChunk.hasMore()) {
Result.SHOULD_FETCH_MORE
@ -132,6 +139,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
roomMemberContentsByUser[stateEvent.stateKey] = stateEvent.content.toModel<RoomMemberContent>()
}
}
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
run processTimelineEvents@{
eventList.forEach { event ->
if (event.eventId == null || event.senderId == null) {
@ -176,10 +184,28 @@ internal class TokenChunkEventPersistor @Inject constructor(
}
liveEventManager.get().dispatchPaginatedEventReceived(event, roomId)
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) {
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.SearchResult
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.executeRequest
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.SearchRequestRoomEvents
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 javax.inject.Inject
@ -47,7 +52,8 @@ internal interface SearchTask : Task<SearchTask.Params, SearchResult> {
internal class DefaultSearchTask @Inject constructor(
private val searchAPI: SearchAPI,
private val globalErrorReceiver: GlobalErrorReceiver
private val globalErrorReceiver: GlobalErrorReceiver,
private val realmSessionProvider: RealmSessionProvider
) : SearchTask {
override suspend fun execute(params: SearchTask.Params): SearchResult {
@ -74,12 +80,22 @@ internal class DefaultSearchTask @Inject constructor(
}
private fun SearchResponse.toDomain(): SearchResult {
val localTimelineEvents = findRootThreadEventsFromDB(searchCategories.roomEvents?.results)
return SearchResult(
nextBatch = searchCategories.roomEvents?.nextBatch,
highlights = searchCategories.roomEvents?.highlights,
results = searchCategories.roomEvents?.results?.map { searchResponseItem ->
val localThreadEventDetails = localTimelineEvents
?.firstOrNull { it.eventId == searchResponseItem.event.eventId }
?.root
?.asDomain()
?.threadDetails
EventAndSender(
searchResponseItem.event,
searchResponseItem.event.apply {
threadDetails = localThreadEventDetails
},
searchResponseItem.event.senderId?.let { senderId ->
searchResponseItem.context?.profileInfo?.get(senderId)
?.let {
@ -94,4 +110,19 @@ internal class DefaultSearchTask @Inject constructor(
}?.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.internal.SessionManager
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.SessionId
import org.matrix.android.sdk.internal.di.WorkManagerProvider
@ -64,6 +65,7 @@ internal class SyncResponseHandler @Inject constructor(
private val aggregatorHandler: SyncResponsePostTreatmentAggregatorHandler,
private val cryptoService: DefaultCryptoService,
private val tokenStore: SyncTokenStore,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val processEventForPushTask: ProcessEventForPushTask,
private val pushRuleService: PushRuleService,
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
@ -101,7 +103,10 @@ internal class SyncResponseHandler @Inject constructor(
val aggregator = SyncResponsePostTreatmentAggregator()
// 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
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.database.helper.addIfNecessary
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.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.EventEntity
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.RoomMemberSummaryEntity
@ -81,6 +84,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource,
@UserId private val userId: String,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val timelineInput: TimelineInput,
private val liveEventService: Lazy<StreamEventsManager>) {
@ -363,10 +367,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
val eventIds = ArrayList<String>(eventList.size)
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
for (event in eventList) {
if (event.eventId == null || event.senderId == null || event.type == null) {
continue
}
eventIds.add(event.eventId)
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) {
decryptIfNeeded(event, roomId)
}
threadsAwarenessHandler.handleIfNeeded(
realm = realm,
roomId = roomId,
event = event)
var contentToInject: String? = null
if (!isInitialSync) {
contentToInject = threadsAwarenessHandler.makeEventThreadAware(realm, roomId, event)
}
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) {
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {
eventId = event.eventId
@ -402,6 +407,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
}
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
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
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
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 io.realm.Realm
import org.matrix.android.sdk.api.session.crypto.CryptoService
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import io.realm.kotlin.where
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.EventType
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.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
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.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.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.asDomain
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.EventEntityFields
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.where
import org.matrix.android.sdk.internal.di.MoshiProvider
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
@ -52,11 +61,16 @@ import javax.inject.Inject
*/
internal class ThreadsAwarenessHandler @Inject constructor(
private val permalinkFactory: PermalinkFactory,
private val cryptoService: CryptoService,
@SessionDatabase private val monarchy: Monarchy,
private val lightweightSettingsStorage: LightweightSettingsStorage,
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
* @param syncResponse the sync response
@ -84,7 +98,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
if (eventList.isNullOrEmpty()) return
val threadsToFetch = emptyMap<String, String>().toMutableMap()
Realm.getInstance(monarchy.realmConfiguration).use { realm ->
Realm.getInstance(monarchy.realmConfiguration).use { realm ->
eventList.asSequence()
.filter {
isThreadEvent(it) && it.roomId != null
@ -139,96 +153,186 @@ internal class ThreadsAwarenessHandler @Inject constructor(
/**
* Handle events mainly coming from the RoomSyncHandler
* @return The content to inject in the roomSyncHandler live events
*/
fun handleIfNeeded(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?,
event: Event,
result: MXEventDecryptionResult): JsonDict? {
return transformThreadToReplyIfNeeded(
realm = realm,
roomId = roomId,
event = event,
decryptedResult = result.clearEvent)
}
/**
* If the event is a thread event then transform/enhance it to a visual Reply Event,
* If the event is not a thread event, null value will be returned
* If there is an error (ex. the root/origin thread event is not found), null willl be returend
*/
private fun transformThreadToReplyIfNeeded(realm: Realm, roomId: String?, event: Event, decryptedResult: JsonDict?): JsonDict? {
fun makeEventThreadAware(realm: Realm,
roomId: String?,
event: Event?,
eventEntity: EventEntity? = null): String? {
event ?: return null
roomId ?: return null
if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null
handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event)
if (!isThreadEvent(event)) return null
val rootThreadEventId = getRootThreadEventId(event) ?: return null
val payload = decryptedResult?.toMutableMap() ?: return null
val body = getValueFromPayload(payload, "body") ?: return null
val msgType = getValueFromPayload(payload, "msgtype") ?: return null
val rootThreadEvent = getEventFromDB(realm, rootThreadEventId) ?: return null
val rootThreadEventSenderId = rootThreadEvent.senderId ?: 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,
eventBody = eventBody,
eventToInject = eventToInject,
eventToInjectBody = eventToInjectBody) ?: return null
// update the event
contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent)
} else {
contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload)
}
decryptIfNeeded(rootThreadEvent, roomId)
// 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
}
val rootThreadEventBody = getValueFromPayload(rootThreadEvent.mxDecryptionResult?.payload?.toMutableMap(), "body")
/**
* Handle for not thread events that we have marked them as root.
* Find relations and inject them accordingly
* @param eventEntity the current eventEntity received
* @param event the current event received
* @return The content to inject in the roomSyncHandler live events
*/
private fun handleRootThreadEventsIfNeeded(realm: Realm, roomId: String, eventEntity: EventEntity?, event: Event): String? {
if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) {
eventEntity?.let {
val eventBody = event.getDecryptedTextSummary() ?: return null
return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true)
}
}
return null
}
val permalink = permalinkFactory.createPermalink(roomId, rootThreadEventId, false)
val userLink = permalinkFactory.createPermalink(rootThreadEventSenderId, false) ?: ""
/**
* 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 messageTextContent = injectEvent(
roomId = roomId,
eventBody = newEventBody,
eventToInject = event,
eventToInjectBody = eventBody) ?: return null
return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent)
}
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(
permalink,
userLink,
rootThreadEventSenderId,
// Remove inner mx_reply tags if any
rootThreadEventBody,
body)
eventIdToInjectSenderId,
eventToInjectBody,
eventBody)
val messageTextContent = MessageTextContent(
msgType = msgType,
return MessageTextContent(
msgType = MessageType.MSGTYPE_TEXT,
format = MessageFormat.FORMAT_MATRIX_HTML,
body = body,
body = eventBody,
formattedBody = replyFormatted
).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) {
try {
if (!event.isEncrypted() || event.mxDecryptionResult != null) return
val messageTextContent = MessageTextContent(
msgType = MessageType.MSGTYPE_TEXT,
format = MessageFormat.FORMAT_MATRIX_HTML,
body = eventBody,
formattedBody = replyFormatted
).toContent()
// 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
}
return updateEventEntity(event, eventEntity, eventPayload, messageTextContent)
}
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
*/
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
@ -255,6 +359,9 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun getRootThreadEventId(event: Event): String? =
event.content.toModel<MessageRelationContent>()?.relatesTo?.eventId
private fun getPreviousEventOrRoot(event: Event): String? =
event.content.toModel<MessageRelationContent>()?.relatesTo?.inReplyTo?.eventId
@Suppress("UNCHECKED_CAST")
private fun getValueFromPayload(payload: JsonDict?, key: String): String? {
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.sync.SyncPresence
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.SessionWorkerParams
import org.matrix.android.sdk.internal.worker.WorkerParamsFactory
@ -58,7 +57,6 @@ internal class SyncWorker(context: Context, workerParameters: WorkerParameters,
) : SessionWorkerParams
@Inject lateinit var syncTask: SyncTask
@Inject lateinit var taskExecutor: TaskExecutor
@Inject lateinit var workManagerProvider: WorkManagerProvider
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
### 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
layout_(.*)="@\+id

View file

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

View file

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

View file

@ -145,7 +145,7 @@ class ElementRobot {
assertDisplayed(R.string.are_you_sure)
clickOn(R.string.action_skip)
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.roompreview.RoomPreviewActivity" />
<activity android:name=".features.home.room.filtered.FilteredRoomsActivity" />
<activity android:name=".features.home.room.threads.ThreadsActivity" />
<activity
android:name=".features.home.room.detail.RoomDetailActivity"
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.LoadingFragment
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.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.LocationSharingFragment
import im.vector.app.features.login.LoginCaptchaFragment
@ -204,8 +205,8 @@ interface FragmentModule {
@Binds
@IntoMap
@FragmentKey(RoomDetailFragment::class)
fun bindRoomDetailFragment(fragment: RoomDetailFragment): Fragment
@FragmentKey(TimelineFragment::class)
fun bindTimelineFragment(fragment: TimelineFragment): Fragment
@Binds
@IntoMap
@ -937,6 +938,11 @@ interface FragmentModule {
@FragmentKey(SpaceLeaveAdvancedFragment::class)
fun bindSpaceLeaveAdvancedFragment(fragment: SpaceLeaveAdvancedFragment): Fragment
@Binds
@IntoMap
@FragmentKey(ThreadListFragment::class)
fun bindThreadListFragment(fragment: ThreadListFragment): Fragment
@Binds
@IntoMap
@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.UserColorAccountDataViewModel
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.search.SearchViewModel
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.onboarding.OnboardingViewModel
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.reactions.EmojiSearchResultViewModel
import im.vector.app.features.room.RequireActiveMembershipViewModel
@ -220,6 +221,11 @@ interface MavericksViewModelModule {
@MavericksViewModelKey(CreateDirectRoomViewModel::class)
fun createDirectRoomViewModelFactory(factory: CreateDirectRoomViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(QrCodeScannerViewModel::class)
fun qrCodeViewModelFactory(factory: QrCodeScannerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(RoomNotificationSettingsViewModel::class)
@ -537,8 +543,8 @@ interface MavericksViewModelModule {
@Binds
@IntoMap
@MavericksViewModelKey(RoomDetailViewModel::class)
fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@MavericksViewModelKey(TimelineViewModel::class)
fun roomDetailViewModelFactory(factory: TimelineViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap

View file

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

View file

@ -129,6 +129,10 @@ fun TextView.setLeftDrawable(drawable: Drawable?) {
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
*/

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 {
return vectorPreferences.showAvatarDisplayNameChangeMessages()
}
fun areThreadMessagesEnabled(): Boolean {
return vectorPreferences.areThreadMessagesEnabled()
}
}

View file

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

View file

@ -18,17 +18,26 @@ package im.vector.app.features.autocomplete.command
import android.content.Context
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.RecyclerViewPresenter
import im.vector.app.features.command.Command
import im.vector.app.features.settings.VectorPreferences
import javax.inject.Inject
class AutocompleteCommandPresenter @Inject constructor(context: Context,
private val controller: AutocompleteCommandController,
private val vectorPreferences: VectorPreferences) :
class AutocompleteCommandPresenter @AssistedInject constructor(
@Assisted val isInThreadTimeline: Boolean,
context: Context,
private val controller: AutocompleteCommandController,
private val vectorPreferences: VectorPreferences) :
RecyclerViewPresenter<Command>(context), AutocompleteClickListener<Command> {
@AssistedFactory
interface Factory {
fun create(isFromThreadTimeline: Boolean): AutocompleteCommandPresenter
}
init {
controller.listener = this
}
@ -46,6 +55,13 @@ class AutocompleteCommandPresenter @Inject constructor(context: Context,
.filter {
!it.isDevCommand || vectorPreferences.developerMode()
}
.filter {
if (vectorPreferences.areThreadMessagesEnabled() && isInThreadTimeline) {
it.isThreadCommand
} else {
true
}
}
.filter {
if (query.isNullOrEmpty()) {
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.home.AvatarRenderer
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.setupDraggable
import kotlinx.parcelize.Parcelize
@ -571,7 +571,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private fun returnToChat() {
val roomId = withState(callViewModel) { it.roomId }
val args = RoomDetailArgs(roomId)
val args = TimelineArgs(roomId)
val intent = RoomDetailActivity.newIntent(this, args).apply {
flags = FLAG_ACTIVITY_CLEAR_TOP
}

View file

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

View file

@ -33,7 +33,7 @@ class CommandParser @Inject constructor() {
* @param textMessage the text message
* @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
return if (!textMessage.startsWith("/")) {
ParsedCommand.ErrorNotACommand
@ -63,6 +63,10 @@ class CommandParser @Inject constructor() {
val slashCommand = messageParts.first()
val message = textMessage.substring(slashCommand.length).trim()
getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
return ParsedCommand.ErrorCommandNotSupportedInThreads(it)
}
when {
Command.PLAIN.matches(slashCommand) -> {
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? {
val partsSize = messageParts.sumOf { it.length }
val gapsNumber = messageParts.size - 1

View file

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

View file

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

View file

@ -22,6 +22,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Async
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.features.analytics.plan.Screen
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.UserListFragmentArgs
import im.vector.app.features.userdirectory.UserListSharedAction
@ -59,6 +64,8 @@ import javax.inject.Inject
class CreateDirectRoomActivity : SimpleFragmentActivity() {
private val viewModel: CreateDirectRoomViewModel by viewModel()
private val qrViewModel: QrCodeScannerViewModel by viewModel()
private lateinit var sharedActionViewModel: UserListSharedActionViewModel
@Inject lateinit var errorFormatter: ErrorFormatter
@ -93,11 +100,38 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
viewModel.onEach(CreateDirectRoomViewState::createAndInviteState) {
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() {
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 ->
if (allGranted) {
addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java)
addFragment(views.container, QrCodeScannerFragment::class.java)
} else if (deniedPermanently) {
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
sealed class CreateDirectRoomViewEvents : VectorViewEvents
sealed class CreateDirectRoomViewEvents : VectorViewEvents {
object InvalidCode : CreateDirectRoomViewEvents()
object DmSelf : CreateDirectRoomViewEvents()
}

View file

@ -34,13 +34,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.raw.RawService
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.user.model.User
class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
initialState: CreateDirectRoomViewState,
private val rawService: RawService,
val session: Session) :
VectorViewModel<CreateDirectRoomViewState, CreateDirectRoomAction, CreateDirectRoomViewEvents>(initialState) {
VectorViewModel<CreateDirectRoomViewState, CreateDirectRoomAction, CreateDirectRoomViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<CreateDirectRoomViewModel, CreateDirectRoomViewState> {
@ -51,15 +54,33 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
override fun handle(action: CreateDirectRoomAction) {
when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action)
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections)
is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action)
}.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.
*/
private fun onSubmitInvitees(action: CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers) {
val existingRoomId = action.selections.singleOrNull()?.getMxId()?.let { userId ->
private fun onSubmitInvitees(selections: Set<PendingSelection>) {
val existingRoomId = selections.singleOrNull()?.getMxId()?.let { userId ->
session.getExistingDirectRoomWithUser(userId)
}
if (existingRoomId != null) {
@ -69,7 +90,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
}
} else {
// 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.home.AvatarRenderer
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.VerificationVectorAlert
import org.matrix.android.sdk.api.session.Session
@ -142,7 +142,7 @@ class IncomingVerificationRequestHandler @Inject constructor(
R.drawable.ic_shield_black,
shouldBeDisplayedIn = { activity ->
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
} ?: true
} else true

View file

@ -550,7 +550,7 @@ class HomeActivity :
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
MatrixToBottomSheet.withLink(deepLink.toString())
.show(supportFragmentManager, "HA#MatrixToBottomSheet")

View file

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

View file

@ -49,9 +49,10 @@ import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem
class AutoCompleter @AssistedInject constructor(
@Assisted val roomId: String,
@Assisted val isInThreadTimeline: Boolean,
private val avatarRenderer: AvatarRenderer,
private val commandAutocompletePolicy: CommandAutocompletePolicy,
private val autocompleteCommandPresenter: AutocompleteCommandPresenter,
AutocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory,
private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory,
private val autocompleteRoomPresenter: AutocompleteRoomPresenter,
private val autocompleteGroupPresenter: AutocompleteGroupPresenter,
@ -62,7 +63,11 @@ class AutoCompleter @AssistedInject constructor(
@AssistedFactory
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

View file

@ -44,7 +44,7 @@ class JoinReplacementRoomBottomSheet :
@Inject
lateinit var errorFormatter: ErrorFormatter
private val viewModel: RoomDetailViewModel by parentFragmentViewModel()
private val viewModel: TimelineViewModel by parentFragmentViewModel()
override val showExpanded: Boolean
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.screen.ScreenEvent
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.matrixto.MatrixToBottomSheet
import im.vector.app.features.navigation.Navigator
@ -97,17 +98,17 @@ class RoomDetailActivity :
super.onCreate(savedInstanceState)
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false)
waitingView = views.waitingView.waitingView
val roomDetailArgs: RoomDetailArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) {
RoomDetailArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!)
val timelineArgs: TimelineArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) {
TimelineArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!)
} else {
intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
}
if (roomDetailArgs == null) return
intent.putExtra(Mavericks.KEY_ARG, roomDetailArgs)
currentRoomId = roomDetailArgs.roomId
if (timelineArgs == null) return
intent.putExtra(Mavericks.KEY_ARG, timelineArgs)
currentRoomId = timelineArgs.roomId
if (isFirstCreation()) {
replaceFragment(views.roomDetailContainer, RoomDetailFragment::class.java, roomDetailArgs)
replaceFragment(views.roomDetailContainer, TimelineFragment::class.java, timelineArgs)
replaceFragment(views.roomDetailDrawerContainer, BreadcrumbsFragment::class.java)
}
@ -145,7 +146,7 @@ class RoomDetailActivity :
if (currentRoomId != switchToRoom.roomId) {
currentRoomId = 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 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 {
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.MavericksState
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.session.events.model.Event
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.RoomSummary
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.WidgetType
@ -67,15 +69,18 @@ data class RoomDetailViewState(
val isAllowedToSetupEncryption: Boolean = true,
val hasFailedSending: Boolean = false,
val jitsiState: JitsiState = JitsiState(),
val switchToParentSpace: Boolean = false
val switchToParentSpace: Boolean = false,
val rootThreadEventId: String? = null,
val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState()
) : MavericksState {
constructor(args: RoomDetailArgs) : this(
constructor(args: TimelineArgs) : this(
roomId = args.roomId,
eventId = args.eventId,
// Also highlight the target event, if any
highlightedEventId = args.eventId,
switchToParentSpace = args.switchToParentSpace
switchToParentSpace = args.switchToParentSpace,
rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId
)
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 isDm() = asyncRoomSummary()?.isDirect == true
fun isThreadTimeline() = rootThreadEventId != null
}

View file

@ -32,7 +32,7 @@ class StartCallActionsHandler(
private val fragment: Fragment,
private val callManager: WebRtcCallManager,
private val vectorPreferences: VectorPreferences,
private val roomDetailViewModel: RoomDetailViewModel,
private val timelineViewModel: TimelineViewModel,
private val startCallActivityResultLauncher: ActivityResultLauncher<Array<String>>,
private val showDialogWithMessage: (String) -> Unit,
private val onTapToReturnToCall: () -> Unit) {
@ -45,7 +45,7 @@ class StartCallActionsHandler(
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
when (roomSummary.joinedMembersCount) {
1 -> {
@ -95,7 +95,7 @@ class StartCallActionsHandler(
.setMessage(R.string.audio_video_meeting_description)
.setPositiveButton(fragment.getString(R.string.create)) { _, _ ->
// 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)
.show()
@ -121,22 +121,22 @@ class StartCallActionsHandler(
private fun safeStartCall2(isVideoCall: Boolean) {
val startCallAction = RoomDetailAction.StartCall(isVideoCall)
roomDetailViewModel.pendingAction = startCallAction
timelineViewModel.pendingAction = startCallAction
if (isVideoCall) {
if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL,
fragment.requireActivity(),
startCallActivityResultLauncher,
R.string.permissions_rationale_msg_camera_and_audio)) {
roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(startCallAction)
timelineViewModel.pendingAction = null
timelineViewModel.handle(startCallAction)
}
} else {
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL,
fragment.requireActivity(),
startCallActivityResultLauncher,
R.string.permissions_rationale_msg_record_audio)) {
roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(startCallAction)
timelineViewModel.pendingAction = null
timelineViewModel.handle(startCallAction)
}
}
}

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